Multiplayer Gamewith Node.js
You're going to run a real 3D multiplayer game from your own computer. Friends open a link, pick a colour, and walk around the same world as you, eating pies, dodging pigs and chatting live.
How it works
The walkie-talkie part is a tool called Socket.io. The part that sends the game page to each browser is called Express. Node.js runs them both.
Words with a dotted gold underline explain themselves. Hover over them, or tap them on a phone or tablet.
Node.js is the program that runs your game server. It comes with npm, which downloads the extra tools your game needs.
- Go to nodejs.org.
- Download the version marked LTS. For Windows, choose the Windows Installer (.msi).
- Open the downloaded file and click Next through the installer, keeping all the default settings.
- On the screen about Tools for Native Modules, leave the box unticked. You don't need it and it takes a long time to install.
- Click Install, then Finish.
If Windows asks "Do you want to allow this app to make changes?", click Yes. If the installer says you need an administrator, ask whoever looks after the computer to run it for you.
On a Mac?
Download the macOS Installer (.pkg) from the same page, open it and click Continue through the steps.
Command Prompt is a window where you type instructions to your computer. You'll use it for the rest of this tutorial.
- Press the Windows key, type
cmdand press Enter. A black window opens. - Type this and press Enter:
>
node -vAsks Node.js which version it is. If it answers, it's installed.
- Then check npm too:
>
npm -v
Two version numbers, something like this (yours may be higher, which is fine):
- 'node' is not recognized: close the Command Prompt window and open a new one. Windows only notices new programs in fresh windows. Still not working? Restart your PC, or run the installer from Step 1 again.
- Please use Command Prompt, not PowerShell. PowerShell can block npm with a "running scripts is disabled" error.
On a Mac?
Press Cmd + Space, type Terminal and press Enter. The commands are exactly the same.
Your game needs its own folder. You'll make one called multiplayer-game in your user folder and step inside it.
- Go to your user folder:
>
cd %USERPROFILE%cd means "change directory". It moves Command Prompt into a folder.
- Make the game folder:
>
mkdir multiplayer-gamemkdir means "make directory". It creates a new, empty folder.
- Step inside it:
>
cd multiplayer-game
The line where you type now ends with your new folder:
"A subdirectory or file already exists" just means you made it before. That's fine, carry on with the cd multiplayer-game command.
If you close Command Prompt later, get back here in one go with cd %USERPROFILE%\multiplayer-game
Go to that folder instead of doing command 1, then do commands 2 and 3 as normal.
- Find the folder's path: open File Explorer, click Desktop, then click the empty part of the address bar and press Ctrl + C.
- In Command Prompt type
cd /dand a space, then right-click to paste the path and press Enter. For example:>cd /d "C:\Users\YourName\Desktop"Keep the quote marks if the path has spaces in it. If OneDrive backs up your Desktop, the path will include OneDrive.
On a Mac?
Use cd ~ instead of the first command. The other two are the same.
Your game uses two free tools: Express sends the game page to browsers, and Socket.io keeps the live walkie-talkie connection with every player.
- Set up the project:
>
npm init -yCreates a file called
package.json, a list that remembers which tools your game uses. - Download the two tools:
>
npm install express socket.ionpm fetches them from the internet into a folder called
node_modules. It can take a minute.
A line like this at the end (the number can be different):
- Yellow WARN lines are normal and safe to ignore.
- Red ERR! lines mentioning network or ETIMEDOUT mean npm couldn't reach the internet. Check your connection and run the command again.
This one file is your whole game: the server, the 3D world, the pigs, the pies and the chat.
- Open a new file in Notepad:
>
notepad server.jsNotepad will ask "Do you want to create a new file?" Click Yes.
- Click Copy code below, then click inside Notepad and press Ctrl + V to paste.
- Press Ctrl + S to save, then close Notepad.
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: { origin: "*" },
pingInterval: 10000,
pingTimeout: 5000
});
const players = {};
io.on('connection', (socket) => {
socket.on('checkShade', (hexColor) => {
if (typeof hexColor !== 'string') return;
const isTaken = Object.values(players).some(p => p.color.toLowerCase() === hexColor.toLowerCase());
socket.emit('shadeResult', { isTaken });
});
socket.on('joinRealm', (data) => {
data = data || {};
const spawnAngle = Math.random() * Math.PI * 2;
const radius = 4 + Math.random() * 3;
players[socket.id] = {
id: socket.id,
name: String(data.name || 'Explorer').slice(0, 16),
color: data.color || '#2563eb',
x: Math.sin(spawnAngle) * radius,
y: 0,
z: Math.cos(spawnAngle) * radius,
rotY: 0,
isMoving: false
};
socket.emit('initRealm', { selfId: socket.id, players });
socket.broadcast.emit('playerJoined', players[socket.id]);
});
socket.on('playerUpdate', (moveData) => {
if (players[socket.id]) {
players[socket.id].x = moveData.x;
players[socket.id].y = moveData.y;
players[socket.id].z = moveData.z;
players[socket.id].rotY = moveData.rotY;
players[socket.id].isMoving = moveData.isMoving;
socket.broadcast.emit('playerMoved', players[socket.id]);
}
});
socket.on('chatMessage', (msg) => {
if (players[socket.id]) {
io.emit('chatMessage', { name: players[socket.id].name, text: String(msg).slice(0, 200), color: players[socket.id].color });
}
});
socket.on('disconnect', () => {
if (players[socket.id]) {
delete players[socket.id];
io.emit('playerLeft', socket.id);
}
});
});
app.get('/', (req, res) => {
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Pie's World</title>
<style>
body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; font-family: 'Segoe UI', sans-serif; background: #0b0e14; touch-action: none; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
#canvas-container { width: 100%; height: 100%; }
#ui {
position: absolute; top: 20px; left: 20px; color: #fff;
background: rgba(15, 23, 42, 0.88); padding: 16px 22px; border-radius: 12px;
backdrop-filter: blur(8px); border: 1px solid rgba(255, 255, 255, 0.15); z-index: 10; width: 220px;
transition: transform 0.3s ease;
}
#ui h2 { margin: 0 0 8px 0; font-size: 18px; color: #38bdf8; text-transform: uppercase; }
#ui p { margin: 4px 0; font-size: 13px; color: #94a3b8; }
#player-count { color: #4ade80; font-weight: bold; }
#pie-count { color: #fbbf24; font-weight: bold; font-size: 15px; }
.health-container { margin: 8px 0; background: #1e293b; border-radius: 6px; overflow: hidden; height: 10px; border: 1px solid #334155; }
#health-fill { width: 100%; height: 100%; background: #4ade80; transition: width 0.2s, background 0.2s; }
.btn {
margin-top: 10px; padding: 8px 14px; background: #2563eb; color: #fff; border: none;
border-radius: 8px; font-weight: bold; cursor: pointer; text-transform: uppercase; font-size: 11px; width: 100%;
}
.btn:hover { background: #3b82f6; }
/* Mobile Responsive UI Adjustments */
@media (max-width: 768px) {
#ui {
top: 10px; left: 10px; padding: 10px 14px; width: 140px;
}
#ui h2 { font-size: 13px; margin-bottom: 4px; }
#ui p { font-size: 11px; margin: 2px 0; }
.health-container { height: 6px; margin: 4px 0; }
.btn { padding: 6px 8px; font-size: 9px; margin-top: 6px; }
#chat-container { width: 240px !important; bottom: 10px !important; left: 10px !important; padding: 8px !important; }
#messages { height: 70px !important; font-size: 11px !important; }
#chat-input { font-size: 11px !important; padding: 6px !important; }
}
#lobby-modal {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(11, 14, 20, 0.94); backdrop-filter: blur(12px);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.modal-box {
background: #0f172a; padding: 32px; border-radius: 16px; width: 340px; text-align: center;
border: 1px solid rgba(56, 189, 248, 0.3); box-sizing: border-box; max-width: 90%;
}
.modal-box h1 { margin: 0 0 8px 0; color: #38bdf8; font-size: 20px; text-transform: uppercase; }
.form-group { margin-bottom: 16px; text-align: left; }
.form-group label { display: block; color: #38bdf8; font-size: 11px; font-weight: bold; margin-bottom: 6px; }
.form-input {
width: 100%; padding: 10px; background: #1e293b; border: 1px solid #334155;
border-radius: 8px; color: #fff; font-size: 14px; box-sizing: border-box; outline: none;
}
.color-row { display: flex; align-items: center; gap: 12px; }
input[type="color"] { -webkit-appearance: none; border: none; width: 44px; height: 44px; border-radius: 8px; cursor: pointer; background: none; }
input[type="color"]::-webkit-color-swatch { border: 2px solid #38bdf8; border-radius: 8px; }
#color-error { color: #ef4444; font-size: 12px; font-weight: bold; margin-top: 8px; display: none; }
#chat-container {
position: absolute; bottom: 20px; left: 20px; width: 320px;
background: rgba(15, 23, 42, 0.88); backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 12px;
padding: 12px; color: white; z-index: 10;
}
#messages {
height: 110px; overflow-y: auto; font-size: 13px; margin-bottom: 8px;
display: flex; flex-direction: column; gap: 4px;
}
.msg { background: rgba(255, 255, 255, 0.06); padding: 5px 8px; border-radius: 6px; word-break: break-word; }
#chat-input {
width: 100%; padding: 8px 10px; border: 1px solid #334155;
border-radius: 6px; background: #1e293b; color: white; box-sizing: border-box; outline: none; font-size: 13px;
}
#joystick-base {
position: absolute; bottom: 30px; right: 30px; width: 120px; height: 120px;
background: rgba(255, 255, 255, 0.08); border: 2px solid rgba(56, 189, 248, 0.3);
border-radius: 50%; display: none; z-index: 20; touch-action: none;
}
#joystick-stick {
position: absolute; top: 35px; left: 35px; width: 50px; height: 50px;
background: rgba(56, 189, 248, 0.6); border-radius: 50%; pointer-events: none;
}
#nametags-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 5; }
.nametag {
position: absolute; background: rgba(15, 23, 42, 0.88); color: #93c5fd; padding: 4px 10px;
border-radius: 6px; font-size: 12px; font-weight: bold; border: 1px solid rgba(56, 189, 248, 0.3);
white-space: nowrap; transform: translate(-50%, -50%); transition: background 0.15s, border-color 0.15s;
}
.nametag.is-you { border-color: #4ade80; color: #4ade80; background: rgba(6, 78, 59, 0.88); }
.nametag.ouch {
background: rgba(220, 38, 38, 0.95) !important;
color: #ffffff !important;
border-color: #f87171 !important;
font-size: 13px !important;
font-weight: 900 !important;
box-shadow: 0 0 12px rgba(239, 68, 68, 0.8);
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="/socket.io/socket.io.js"></script>
</head>
<body>
<div id="lobby-modal">
<div class="modal-box">
<h1>Pie's World</h1>
<div class="form-group">
<label>Explorer Name</label>
<input type="text" id="username-input" class="form-input" maxlength="16" value="Explorer_${Math.floor(100 + Math.random() * 899)}">
</div>
<div class="form-group">
<label>Select Hex Color / Shade</label>
<div class="color-row">
<input type="color" id="hex-picker" value="#2563eb">
<span style="color:#94a3b8; font-size:12px;">Unique hex required</span>
</div>
</div>
<div id="color-error">⚠️ That shade is already taken!</div>
<button class="btn" id="join-btn" style="width: 100%; padding: 12px; margin-top: 10px;">ENTER REALM</button>
</div>
</div>
<!-- UI on the Left (Compact on mobile) -->
<div id="ui">
<h2>Pie's World</h2>
<p>Players: <span id="player-count">0</span></p>
<p>Pies: <span id="pie-count">0</span></p>
<p>Health: <span id="health-text">100%</span></p>
<div class="health-container"><div id="health-fill"></div></div>
<p style="font-size: 10px; color: #94a3b8; margin-top: 4px; line-height: 1.2;">Avoid pigs! Eat pies for boost & health.</p>
<button class="btn" onclick="teleportSpawn()">Spawn</button>
</div>
<div id="chat-container">
<div id="messages"></div>
<input id="chat-input" placeholder="Type message & press Enter..." />
</div>
<div id="joystick-base">
<div id="joystick-stick"></div>
</div>
<div id="nametags-container"></div>
<div id="canvas-container"></div>
<script>
const socket = io();
let mySelfId = null;
let isJoined = false;
const hexPicker = document.getElementById('hex-picker');
const colorError = document.getElementById('color-error');
const joinBtn = document.getElementById('join-btn');
const usernameInput = document.getElementById('username-input');
// Check the starting colour as soon as we connect
socket.on('connect', () => socket.emit('checkShade', hexPicker.value));
hexPicker.addEventListener('input', () => {
socket.emit('checkShade', hexPicker.value);
});
socket.on('shadeResult', (data) => {
if (data.isTaken) {
colorError.style.display = 'block';
joinBtn.disabled = true;
} else {
colorError.style.display = 'none';
joinBtn.disabled = false;
}
});
joinBtn.onclick = () => {
const name = usernameInput.value.trim() || 'Explorer';
const color = hexPicker.value;
socket.emit('joinRealm', { name, color });
document.getElementById('lobby-modal').style.display = 'none';
localCharacter.mesh.visible = true;
localTag.style.display = 'block';
isJoined = true;
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
document.getElementById('joystick-base').style.display = 'block';
}
};
// Synthesized Clink Sound Effect for Pie Collection
function playClinkSound() {
try {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const osc = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(880, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(1760, audioCtx.currentTime + 0.15);
gainNode.gain.setValueAtTime(0.25, audioCtx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.3);
osc.connect(gainNode);
gainNode.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.3);
} catch(e) {}
}
// 3D Scene Setup
const container = document.getElementById('canvas-container');
const nametagsContainer = document.getElementById('nametags-container');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0e1420);
scene.fog = new THREE.FogExp2(0x0e1420, 0.015);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
container.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xdbeafe, 0.6));
const dirLight = new THREE.DirectionalLight(0x93c5fd, 1.0);
dirLight.position.set(30, 50, 30);
dirLight.castShadow = true;
scene.add(dirLight);
// Flat Grid Terrain
const terrain = new THREE.Mesh(
new THREE.PlaneGeometry(300, 300),
new THREE.MeshStandardMaterial({ color: 0x0f172a, roughness: 0.9 })
);
terrain.rotation.x = -Math.PI / 2;
terrain.receiveShadow = true;
scene.add(terrain);
const gridHelper = new THREE.GridHelper(300, 60, 0x38bdf8, 0x1e293b);
gridHelper.position.y = 0.01;
scene.add(gridHelper);
// Snow Particle System
const snowCount = 600;
const snowGeo = new THREE.BufferGeometry();
const snowPositions = new Float32Array(snowCount * 3);
for(let i=0; i<snowCount*3; i+=3) {
snowPositions[i] = (Math.random() - 0.5) * 200;
snowPositions[i+1] = Math.random() * 50;
snowPositions[i+2] = (Math.random() - 0.5) * 200;
}
snowGeo.setAttribute('position', new THREE.BufferAttribute(snowPositions, 3));
const snowMat = new THREE.PointsMaterial({ color: 0xffffff, size: 0.6, transparent: true, opacity: 0.7 });
const snowSystem = new THREE.Points(snowGeo, snowMat);
scene.add(snowSystem);
// Clouds in Sky
function createCloud(x, y, z) {
const cloudGroup = new THREE.Group();
const cloudMat = new THREE.MeshStandardMaterial({ color: 0x334155, roughness: 1.0 });
for(let i=0; i<4; i++) {
const puff = new THREE.Mesh(new THREE.BoxGeometry(6 + Math.random()*4, 2, 4), cloudMat);
puff.position.set((i-1.5)*3, Math.random()*1, (Math.random()-0.5)*2);
cloudGroup.add(puff);
}
cloudGroup.position.set(x, y, z);
scene.add(cloudGroup);
}
for(let i=0; i<8; i++) {
createCloud((Math.random()-0.5)*150, 35 + Math.random()*15, (Math.random()-0.5)*150);
}
// Aggressive Pigs
const pigs = [];
function createPigMesh() {
const group = new THREE.Group();
const pinkMat = new THREE.MeshStandardMaterial({ color: 0xf472b6, roughness: 0.7 });
const body = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.6, 1.2), pinkMat);
body.position.y = 0.5; body.castShadow = true; group.add(body);
const head = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.5, 0.5), pinkMat);
head.position.set(0, 0.7, 0.7); head.castShadow = true; group.add(head);
const snout = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.2, 0.1), new THREE.MeshStandardMaterial({ color: 0xdb2777 }));
snout.position.set(0, 0.65, 0.96); group.add(snout);
return group;
}
for (let i = 0; i < 40; i++) {
const pigGroup = createPigMesh();
const angle = Math.random() * Math.PI * 2;
const dist = 10 + Math.random() * 90;
pigGroup.position.set(Math.cos(angle) * dist, 0, Math.sin(angle) * dist);
scene.add(pigGroup);
pigs.push({
mesh: pigGroup, x: pigGroup.position.x, z: pigGroup.position.z,
wanderAngle: Math.random() * Math.PI * 2, speed: 0.04 + Math.random() * 0.02
});
}
// Collectible Pies
const pies = [];
function createPieMesh() {
const group = new THREE.Group();
const crustMat = new THREE.MeshStandardMaterial({ color: 0xd97706, roughness: 0.5 });
const fillingMat = new THREE.MeshStandardMaterial({ color: 0x991b1b, roughness: 0.4 });
const crust = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.4, 0.3, 8), crustMat);
crust.position.y = 0.15; crust.castShadow = true; group.add(crust);
const filling = new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.42, 0.2, 8), fillingMat);
filling.position.y = 0.22; group.add(filling);
return group;
}
for (let i = 0; i < 35; i++) {
const pieMesh = createPieMesh();
const angle = Math.random() * Math.PI * 2;
const dist = 5 + Math.random() * 85;
pieMesh.position.set(Math.cos(angle) * dist, 0, Math.sin(angle) * dist);
scene.add(pieMesh);
pies.push({ mesh: pieMesh, x: pieMesh.position.x, z: pieMesh.position.z, active: true });
}
function createExplorerCharacter(hexColor) {
const group = new THREE.Group();
const coatMat = new THREE.MeshStandardMaterial({ color: hexColor, roughness: 0.4 });
const torso = new THREE.Mesh(new THREE.BoxGeometry(0.9, 1.2, 0.5), coatMat);
torso.position.y = 1.5; torso.castShadow = true; group.add(torso);
const head = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.5, 0.5), new THREE.MeshStandardMaterial({ color: 0xfbcfe8 }));
head.position.y = 2.45; head.castShadow = true; group.add(head);
const visor = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.12, 0.15), new THREE.MeshStandardMaterial({ color: 0x38bdf8, emissive: 0x38bdf8 }));
visor.position.set(0, 2.48, 0.22); group.add(visor);
const happyGlow = new THREE.PointLight(0xfbbf24, 0, 4);
happyGlow.position.set(0, 2, 0);
group.add(happyGlow);
const createLimb = (x, y) => {
const pivot = new THREE.Group(); pivot.position.set(x, y, 0);
const mesh = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.9, 0.28), coatMat);
mesh.position.y = -0.45; mesh.castShadow = true; pivot.add(mesh); group.add(pivot);
return pivot;
};
const leftArm = createLimb(-0.62, 2.0); const rightArm = createLimb(0.62, 2.0);
const leftLeg = createLimb(-0.25, 0.9); const rightLeg = createLimb(0.25, 0.9);
return {
mesh: group, coatMat, happyGlow,
animate: function(time, isMoving) {
if (isMoving) {
const angle = Math.sin(time * 10) * 0.6;
leftArm.rotation.x = angle; rightArm.rotation.x = -angle;
leftLeg.rotation.x = -angle; rightLeg.rotation.x = angle;
} else {
leftArm.rotation.x = rightArm.rotation.x = leftLeg.rotation.x = rightLeg.rotation.x = 0;
}
}
};
}
const localState = {
x: 0, y: 0, z: 0, rotY: 0, baseSpeed: 0.16, speed: 0.16,
isMoving: false, isColliding: false, hitPig: false,
health: 100, pieScore: 0, boostTimer: 0, glowTimer: 0
};
const localCharacter = createExplorerCharacter('#2563eb');
localCharacter.mesh.visible = false;
scene.add(localCharacter.mesh);
const localTag = document.createElement('div');
localTag.className = 'nametag is-you';
localTag.style.display = 'none';
nametagsContainer.appendChild(localTag);
const remotePlayers = new Map();
function teleportSpawn() {
localState.x = (Math.random() - 0.5) * 6;
localState.z = (Math.random() - 0.5) * 6;
}
socket.on('initRealm', (data) => {
mySelfId = data.selfId;
const selfData = data.players[mySelfId];
localState.x = selfData.x;
localState.z = selfData.z;
localCharacter.coatMat.color.set(selfData.color);
Object.values(data.players).forEach(p => {
if (p.id !== mySelfId) addRemotePlayer(p);
});
updatePlayerCount();
});
socket.on('playerJoined', (p) => {
if (p.id !== mySelfId && !remotePlayers.has(p.id)) {
addRemotePlayer(p);
updatePlayerCount();
}
});
socket.on('playerMoved', (p) => {
if (remotePlayers.has(p.id)) {
const remote = remotePlayers.get(p.id);
remote.targetX = p.x;
remote.targetY = p.y;
remote.targetZ = p.z;
remote.targetRotY = p.rotY;
remote.isMoving = p.isMoving;
}
});
socket.on('playerLeft', (id) => {
if (remotePlayers.has(id)) {
const remote = remotePlayers.get(id);
scene.remove(remote.character.mesh);
remote.tag.remove();
remotePlayers.delete(id);
updatePlayerCount();
}
});
function addRemotePlayer(p) {
const char = createExplorerCharacter(p.color);
char.mesh.position.set(p.x, p.y, p.z);
scene.add(char.mesh);
const tag = document.createElement('div');
tag.className = 'nametag';
tag.innerText = p.name;
nametagsContainer.appendChild(tag);
remotePlayers.set(p.id, {
character: char, tag: tag, name: p.name,
targetX: p.x, targetY: p.y, targetZ: p.z, targetRotY: p.rotY,
isMoving: false, isColliding: false
});
}
function updatePlayerCount() {
document.getElementById('player-count').innerText = isJoined ? remotePlayers.size + 1 : remotePlayers.size;
}
const chatInput = document.getElementById('chat-input');
const messagesDiv = document.getElementById('messages');
chatInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && chatInput.value.trim() !== '') {
socket.emit('chatMessage', chatInput.value.trim());
chatInput.value = '';
}
});
// Stops chat messages from injecting HTML into the page
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
socket.on('chatMessage', (data) => {
const msg = document.createElement('div');
msg.className = 'msg';
msg.innerHTML = \`<b style="color: \${escapeHtml(data.color || '#38bdf8')}">\${escapeHtml(data.name)}:</b> \${escapeHtml(data.text)}\`;
messagesDiv.appendChild(msg);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
});
const keys = {};
window.addEventListener('keydown', e => {
if (document.activeElement !== chatInput) keys[e.key.toLowerCase()] = true;
});
window.addEventListener('keyup', e => keys[e.key.toLowerCase()] = false);
// Optimized Smooth Touch Controls (Fixed Joystick Lag)
let joystickActive = false;
let activeTouchId = null;
let joyVector = { x: 0, y: 0 };
const joystickBase = document.getElementById('joystick-base');
const joystickStick = document.getElementById('joystick-stick');
let baseRect;
joystickBase.addEventListener('touchstart', (e) => {
if (joystickActive) return;
const touch = e.changedTouches[0];
activeTouchId = touch.identifier;
joystickActive = true;
baseRect = joystickBase.getBoundingClientRect();
handleTouchMove(touch);
e.preventDefault();
}, { passive: false });
window.addEventListener('touchmove', (e) => {
if (!joystickActive) return;
for (let i = 0; i < e.changedTouches.length; i++) {
const touch = e.changedTouches[i];
if (touch.identifier === activeTouchId) {
handleTouchMove(touch);
break;
}
}
e.preventDefault();
}, { passive: false });
window.addEventListener('touchend', (e) => {
if (!joystickActive) return;
for (let i = 0; i < e.changedTouches.length; i++) {
if (e.changedTouches[i].identifier === activeTouchId) {
joystickActive = false;
activeTouchId = null;
joyVector = { x: 0, y: 0 };
joystickStick.style.transform = \`translate(0px, 0px)\`;
break;
}
}
});
function handleTouchMove(touch) {
const centerX = baseRect.left + baseRect.width / 2;
const centerY = baseRect.top + baseRect.height / 2;
let dx = touch.clientX - centerX;
let dy = touch.clientY - centerY;
const dist = Math.hypot(dx, dy);
const maxRadius = 45;
if (dist > maxRadius) {
dx = (dx / dist) * maxRadius;
dy = (dy / dist) * maxRadius;
}
joystickStick.style.transform = \`translate(\${dx}px, \${dy}px)\`;
joyVector.x = dx / maxRadius;
joyVector.y = dy / maxRadius;
}
const projVec = new THREE.Vector3();
function updateTagPos(mesh, tagElement, text, isOuch, isYou) {
if (isOuch) {
tagElement.className = 'nametag ouch';
tagElement.innerText = text.includes('oi pie') ? '💥 OUCH! oi pie' : \`💥 OUCH! \${text}\`;
} else {
tagElement.className = isYou ? 'nametag is-you' : 'nametag';
tagElement.innerText = text;
}
mesh.getWorldPosition(projVec);
projVec.y += 3.1;
projVec.project(camera);
if (projVec.z > 1) { tagElement.style.display = 'none'; return; }
tagElement.style.display = 'block';
const x = (projVec.x * 0.5 + 0.5) * window.innerWidth;
const y = (-projVec.y * 0.5 + 0.5) * window.innerHeight;
tagElement.style.left = \`\${x}px\`;
tagElement.style.top = \`\${y}px\`;
}
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
// Snow falling
const positions = snowGeo.attributes.position.array;
for(let i=1; i<positions.length; i+=3) {
positions[i] -= 0.15;
if(positions[i] < 0) positions[i] = 50;
}
snowGeo.attributes.position.needsUpdate = true;
// Aggressive Pigs Chase Logic
pigs.forEach(pig => {
if (isJoined) {
const distToPlayer = Math.hypot(localState.x - pig.x, localState.z - pig.z);
if (distToPlayer < 35) {
const targetAngle = Math.atan2(localState.x - pig.x, localState.z - pig.z);
pig.wanderAngle = targetAngle;
pig.speed = 0.075;
} else {
pig.wanderAngle += (Math.random() - 0.5) * 0.1;
pig.speed = 0.035;
}
}
pig.x += Math.sin(pig.wanderAngle) * pig.speed;
pig.z += Math.cos(pig.wanderAngle) * pig.speed;
pig.mesh.position.set(pig.x, 0, pig.z);
pig.mesh.rotation.y = pig.wanderAngle;
});
if (isJoined) {
if (localState.boostTimer > 0) {
localState.boostTimer -= 0.016;
localState.speed = localState.baseSpeed * 1.8;
} else {
localState.speed = localState.baseSpeed;
}
if (localState.glowTimer > 0) {
localState.glowTimer -= 0.016;
localCharacter.happyGlow.intensity = 3.5;
} else {
localCharacter.happyGlow.intensity = 0;
}
let moveX = 0, moveZ = 0;
if (keys['w'] || keys['arrowup']) moveZ -= 1;
if (keys['s'] || keys['arrowdown']) moveZ += 1;
if (keys['a'] || keys['arrowleft']) moveX -= 1;
if (keys['d'] || keys['arrowright']) moveX += 1;
if (joystickActive) {
moveX += joyVector.x;
moveZ += joyVector.y;
}
localState.isMoving = (Math.abs(moveX) > 0.05 || Math.abs(moveZ) > 0.05);
if (localState.isMoving) {
const angle = Math.atan2(moveX, moveZ);
localState.rotY = angle;
localState.x += Math.sin(angle) * localState.speed * Math.min(Math.hypot(moveX, moveZ), 1);
localState.z += Math.cos(angle) * localState.speed * Math.min(Math.hypot(moveX, moveZ), 1);
}
localCharacter.mesh.position.set(localState.x, localState.y, localState.z);
localCharacter.mesh.rotation.y = localState.rotY;
localCharacter.animate(time, localState.isMoving);
// Pie Collection Check
pies.forEach(pie => {
if (pie.active) {
const dist = Math.hypot(localState.x - pie.x, localState.z - pie.z);
if (dist < 1.0) {
pie.active = false;
pie.mesh.visible = false;
localState.pieScore++;
localState.boostTimer = 6.0;
localState.glowTimer = 2.5;
localState.health = Math.min(100, localState.health + 25);
document.getElementById('pie-count').innerText = localState.pieScore;
playClinkSound();
setTimeout(() => {
const angle = Math.random() * Math.PI * 2;
const dist = 5 + Math.random() * 85;
pie.x = Math.cos(angle) * dist;
pie.z = Math.sin(angle) * dist;
pie.mesh.position.set(pie.x, 0, pie.z);
pie.active = true;
pie.mesh.visible = true;
}, 8000);
}
}
});
// Pig Collision & Health Drain
localState.isColliding = false;
localState.hitPig = false;
pigs.forEach((pig) => {
const dist = Math.hypot(localState.x - pig.x, localState.z - pig.z);
if (dist < 1.0) {
localState.isColliding = true;
localState.hitPig = true;
if (localState.health > 10) {
localState.health -= 0.6;
}
}
});
document.getElementById('health-text').innerText = \`\${Math.round(localState.health)}%\`;
const healthFill = document.getElementById('health-fill');
healthFill.style.width = \`\${localState.health}%\`;
healthFill.style.background = localState.boostTimer > 0 ? '#38bdf8' : '#4ade80';
updateTagPos(localCharacter.mesh, localTag, localState.hitPig ? 'oi pie' : \`[YOU] \${usernameInput.value}\`, localState.isColliding, true);
socket.emit('playerUpdate', {
x: localState.x, y: localState.y, z: localState.z,
rotY: localState.rotY, isMoving: localState.isMoving
});
camera.position.x += (localState.x - camera.position.x) * 0.1;
camera.position.y += (localState.y + 6 - camera.position.y) * 0.1;
camera.position.z += (localState.z + 12 - camera.position.z) * 0.1;
camera.lookAt(localState.x, localState.y + 1.5, localState.z);
remotePlayers.forEach((remote) => {
remote.character.mesh.position.x += (remote.targetX - remote.character.mesh.position.x) * 0.3;
remote.character.mesh.position.y += (remote.targetY - remote.character.mesh.position.y) * 0.3;
remote.character.mesh.position.z += (remote.targetZ - remote.character.mesh.position.z) * 0.3;
let diff = remote.targetRotY - remote.character.mesh.rotation.y;
diff = Math.atan2(Math.sin(diff), Math.cos(diff));
remote.character.mesh.rotation.y += diff * 0.3;
remote.character.animate(time, remote.isMoving);
updateTagPos(remote.character.mesh, remote.tag, remote.name, remote.isColliding, false);
});
}
renderer.render(scene, camera);
}
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
animate();
</script>
</body>
</html>
`);
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`🚀 Real-Time Game Server running on http://localhost:${PORT}`);
});
- Check the file is there:
>
dir
Four things listed: node_modules, package.json, package-lock.json and server.js.
- If you see server.js.txt, Notepad added a hidden extension. Rename it with
ren server.js.txt server.js - If you used the Download button, the file went to your Downloads folder. Move it here with
move %USERPROFILE%\Downloads\server.js .
On a Mac?
Click Download above, then move the file into your folder with mv ~/Downloads/server.js . (TextEdit can change quote marks when you paste, which breaks code, so downloading is safer.)
Time to switch it on. While this window stays open, your game is running.
- Start the server:
>
node server.js - If a Windows Security box pops up about Node.js, tick Private networks and click Allow. This lets friends on your Wi-Fi join later.
- Leave this window open. Minimise it if you like, but don't close it.
- Cannot find module 'express': you're in the wrong folder, or Step 4 didn't finish. Run
cd %USERPROFILE%\multiplayer-gamethennpm install express socket.ioand try again. - Cannot find module ...server.js: the file isn't in this folder, or it's called server.js.txt. Go back to Step 5.
- EADDRINUSE: the server is already running in another window. Close that window, or use another port: type
set PORT=3001, press Enter, then runnode server.jsand use port 3001 instead of 3000 from now on. - SyntaxError: part of the code didn't paste. Open it again with
notepad server.js, press Ctrl + A then Delete, and paste the code again.
To stop the server, click its window and press Ctrl + C. To start again, run node server.js.
Your game is live on your computer. Open it in a browser and jump in.
- Open Chrome or Edge and go to localhost: http://localhost:3000
- Type a name, pick a colour, and click Enter realm.
- Now open a second tab with the same address and join again with a different colour. Walk around in one tab and watch it move in the other. That's multiplayer.
How to play
- Move with W A S D or the arrow keys. On phones, a joystick appears.
- Eat pies for a speed boost and extra health. Avoid the pigs, they chase you.
- Type in the chat box and press Enter to talk to everyone.
- Lost? Click Spawn to jump back to the middle.
- Page won't load: check the server window is still open and showing the "running" message.
- Black screen: the 3D engine loads from the internet, so you need to be online. Refresh the page.
- "That shade is already taken": someone else has that exact colour. Pick a slightly different one.
Anyone connected to the same Wi-Fi as you can join, including phones and tablets.
- Open a new Command Prompt window (keep the server one running) and type:
>
ipconfig - Find the line IPv4 Address under your Wi-Fi (or Ethernet) section. It looks like
192.168.1.42. - On your friend's device, open a browser and go to that address followed by
:3000, for examplehttp://192.168.1.42:3000
- Page won't load on their device: Windows Firewall is probably blocking it. Open Windows Security, go to Firewall & network protection, then Allow an app through firewall, click Change settings, and tick Private next to Node.js.
- Make sure your Wi-Fi is set to Private: Settings, Network & internet, Wi-Fi, click your network, choose Private network.
- School, hotel and café Wi-Fi often stop devices seeing each other. Use the next step instead.
On a Mac?
Find your address with ipconfig getifaddr en0. If macOS asks whether to accept incoming connections for node, click Allow.
A tunnel gives your game a public web link, so friends anywhere can join while your PC is running it.
- Keep the server window open. Open another new Command Prompt window and type:
>
npx localtunnel --port 3000The first time, it asks Ok to proceed? (y). Type
yand press Enter. - It prints a link like
https://funny-cats-dance.loca.lt. Send that to your friends. - When a friend first opens it, they'll see a page asking for a tunnel password. On your PC, visit loca.lt/mytunnelpassword and send them the number it shows.
Try Cloudflare's free tunnel instead. Install it once:
winget install --id Cloudflare.cloudflaredClose that window, open a new one, and run:
cloudflared tunnel --url http://localhost:3000Look for a link ending in trycloudflare.com. No password needed.
Anyone with the link can join and chat, so only share it with people you know. The link changes each time you start the tunnel. Press Ctrl + C in the tunnel window to stop sharing.
Want the game online even when your PC is off? You can host it free on Render. This step is optional.
Put your files on GitHub
- Make a free GitHub account at github.com.
- Click New repository, name it
multiplayer-game, and click Create repository. - Click uploading an existing file and drag in
server.js,package.jsonandpackage-lock.json. Don't upload thenode_modulesfolder; Render downloads it itself. - Click Commit changes.
Launch it on Render
- Sign up to Render at render.com using your GitHub account.
- Click New, then Web Service, and choose your
multiplayer-gamerepository. - Set Build command to
npm installand Start command tonode server.js. Choose the Free instance type. - Click Deploy. After a few minutes you'll get a link ending in
onrender.com.
Free Render servers go to sleep when nobody has visited for a while. The first visit after that can take up to a minute to wake up. To update the game, edit server.js on GitHub and Render redeploys it automatically.
You've done the whole journey once. Here's the secret: every Node.js project starts in exactly the same way. Once you know the recipe, starting something new takes about two minutes.
Lots of people forget this part, so keep this page handy. Think of it like baking a cake:
server.js file.node server.js.Node.js only needs installing once, like buying an oven. You don't do Steps 1 and 2 again.
Part 1A new project on your desktop
We'll make a folder called my-new-game right on your Desktop, so it's easy to find.
- Open Command Prompt (Windows key, type
cmd, Enter) and go to your Desktop:>cd %USERPROFILE%\DesktopIf it says "The system cannot find the path", your Desktop is kept in OneDrive. Try
cd %USERPROFILE%\OneDrive\Desktopinstead. - Get a bowl. Make the new folder and step inside it:
>
mkdir my-new-game>cd my-new-gamePick any name you like, but use small letters and dashes, no spaces. A folder icon will pop up on your Desktop.
- Add ingredients. Start the project and install the tools:
>
npm init -y>npm install express socket.ioEvery new project needs its own copy of the tools, in its own folder.
- Write the recipe. Make the file and paste in the starter code below:
>
notepad server.jsClick Yes to create the file, paste the code, press Ctrl + S, then close Notepad.
// ===== My new project =====
// A tiny multiplayer starter: one button that EVERYONE shares.
// Open it in two tabs and click. Both tabs update at the same time!
const express = require('express'); // sends web pages
const http = require('http'); // Node's built-in web server
const { Server } = require('socket.io'); // the live "walkie-talkie"
const app = express();
const server = http.createServer(app);
const io = new Server(server);
let clicks = 0; // kept in the server's memory, shared by everyone
io.on('connection', (socket) => {
socket.emit('clicks', clicks); // tell the new player the score
socket.on('click', () => {
clicks++; // someone clicked...
io.emit('clicks', clicks); // ...so tell EVERYONE
});
});
app.get('/', (req, res) => {
res.send(`<!DOCTYPE html>
<html>
<head><title>My new project</title></head>
<body style="font-family:sans-serif; text-align:center; padding-top:80px; background:#fffaf0;">
<h1>My new project</h1>
<p>Everyone shares this button. Open two tabs and try it!</p>
<button id="btn" style="font-size:24px; padding:12px 28px; cursor:pointer;">Click me</button>
<h2 id="count">0</h2>
<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io();
document.getElementById('btn').onclick = () => socket.emit('click');
socket.on('clicks', (n) => document.getElementById('count').textContent = n);
</script>
</body>
</html>`);
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => console.log('My new project is running on http://localhost:' + PORT));
- Switch the oven on.
>
node server.js - Open http://localhost:3000 in two tabs and click the button. Both tabs count up together. That's your new multiplayer project!
- EADDRINUSE: your old game is still running and using port 3000. Find its window and press Ctrl + C to stop it, then try again.
- Cannot find module 'express': you skipped the install, or you're in a different folder. Check the line you type on ends with
my-new-game>
Change the words inside <h1> or the button's colour, save, restart the server and refresh. This little file is a blank canvas for your own ideas: a shared drawing board, a quiz, a chat room.
A new project on a server
A server is just a computer in a big building somewhere that stays switched on all the time. You don't sit in front of it. Instead you type commands into it from your own PC, over the internet. It's like a remote control for a faraway computer.
Most servers run Linux, not Windows, so a few commands have different names. The recipe is exactly the same though. This part assumes Node.js is already installed on your server.
- Connect to your server. In Command Prompt on your PC, type this with your server's username and address:
>
ssh yourname@your-server-ipType
yesif it asks about a fingerprint, then your password. (The password stays invisible while you type. That's normal.) The prompt now ends with$, meaning you're typing on the server. - Get a bowl. Make the folder in your server home folder and step inside:
$
mkdir my-new-game$cd my-new-game - Add ingredients. Same as on your PC:
$
npm init -y$npm install express socket.io - Write the recipe. Servers don't have Notepad, so use nano, a simple editor that works inside the window:
$
nano server.jsCopy the starter code above, then right-click in the window to paste. Save with Ctrl + O then Enter. Leave nano with Ctrl + X.
- Switch the oven on.
$
node server.jsOn your PC, open
http://your-server-ip:3000in a browser.
The server's firewall is probably blocking port 3000. Open it with:
sudo ufw allow 3000Some hosting companies (like Oracle or AWS) also have their own firewall on their website where you need to allow port 3000.
If you close the window, node server.js stops too. PM2 keeps it running in the background. Stop the server first with Ctrl + C, then:
sudo npm install -g pm2pm2 start server.js --name my-new-gameIf the first line says "npm: command not found", run it again without the word sudo.
See what's running with pm2 list. Stop it with pm2 stop my-new-game.
Your cheat card: start any new project
Go to where you want the project, then:
mkdir my-new-gamecd my-new-gamenpm init -ynpm install express socket.ionotepad server.jsnode server.jsOn a server or Mac, swap notepad for nano.
You installed Node.js, built a real-time server and got people playing together in a 3D world. That's the same idea behind big online games.
Make it yours
Open the code with notepad server.js, change something, save, then restart the server (Ctrl + C, then node server.js) and refresh the browser.
- Game name: search for
Pie's World(Ctrl + F in Notepad) and change it. - Number of pigs: find
i < 40under "Aggressive Pigs" and change 40. - Walking speed: change
baseSpeed: 0.16, speed: 0.16to a bigger number, like 0.25. - Snow: change
snowCount = 600to 2000 for a blizzard.
Next time you want to play
cd %USERPROFILE%\multiplayer-gamenode server.jsThen open http://localhost:3000.
Use the ← and → arrow keys to move between steps.