Multiplayer Game with Node.js
Home

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.

About 30 minutesGo at your own pace. Your place is saved.
A Windows PCMac notes are included wherever things differ.
No coding neededYou'll copy one file and type a few commands.

How it works

The slow wayLike posting a letter every second asking "has anyone moved?" Each reply has to be written and sent back, so everything lags.
The Node.js wayLike an open walkie-talkie with every player. The server keeps everyone's position in memory and passes each move on in a few milliseconds.

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.

Not sure what a word means?

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.

  1. Go to nodejs.org.
  2. Download the version marked LTS. For Windows, choose the Windows Installer (.msi).
  3. Open the downloaded file and click Next through the installer, keeping all the default settings.
  4. 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.
  5. Click Install, then Finish.
If something goes wrong

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.

  1. Press the Windows key, type cmd and press Enter. A black window opens.
  2. Type this and press Enter:
    >node -v

    Asks Node.js which version it is. If it answers, it's installed.

  3. Then check npm too:
    >npm -v
You should see

Two version numbers, something like this (yours may be higher, which is fine):

v22.20.0 10.9.3
If something goes wrong
  • '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.

  1. Go to your user folder:
    >cd %USERPROFILE%

    cd means "change directory". It moves Command Prompt into a folder.

  2. Make the game folder:
    >mkdir multiplayer-game

    mkdir means "make directory". It creates a new, empty folder.

  3. Step inside it:
    >cd multiplayer-game
You should see

The line where you type now ends with your new folder:

C:\Users\YourName\multiplayer-game>
If something goes wrong

"A subdirectory or file already exists" just means you made it before. That's fine, carry on with the cd multiplayer-game command.

Tip

If you close Command Prompt later, get back here in one go with cd %USERPROFILE%\multiplayer-game

Want the folder somewhere else, like your Desktop?

Go to that folder instead of doing command 1, then do commands 2 and 3 as normal.

  1. Find the folder's path: open File Explorer, click Desktop, then click the empty part of the address bar and press Ctrl + C.
  2. In Command Prompt type cd /d and 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.

  1. Set up the project:
    >npm init -y

    Creates a file called package.json, a list that remembers which tools your game uses.

  2. Download the two tools:
    >npm install express socket.io

    npm fetches them from the internet into a folder called node_modules. It can take a minute.

You should see

A line like this at the end (the number can be different):

added 90 packages, and audited 91 packages in 4s found 0 vulnerabilities
If something goes wrong
  • 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.

  1. Open a new file in Notepad:
    >notepad server.js

    Notepad will ask "Do you want to create a new file?" Click Yes.

  2. Click Copy code below, then click inside Notepad and press Ctrl + V to paste.
  3. Press Ctrl + S to save, then close Notepad.
server.js803 lines
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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}`);
});
  1. Check the file is there:
    >dir
You should see

Four things listed: node_modules, package.json, package-lock.json and server.js.

If something goes wrong
  • 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.

  1. Start the server:
    >node server.js
  2. 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.
  3. Leave this window open. Minimise it if you like, but don't close it.
You should see
🚀 Real-Time Game Server running on http://localhost:3000
If something goes wrong
  • Cannot find module 'express': you're in the wrong folder, or Step 4 didn't finish. Run cd %USERPROFILE%\multiplayer-game then npm install express socket.io and 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 run node server.js and 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.
Tip

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.

  1. Open Chrome or Edge and go to localhost: http://localhost:3000
  2. Type a name, pick a colour, and click Enter realm.
  3. 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.
If something goes wrong
  • 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.

  1. Open a new Command Prompt window (keep the server one running) and type:
    >ipconfig
  2. Find the line IPv4 Address under your Wi-Fi (or Ethernet) section. It looks like 192.168.1.42.
  3. On your friend's device, open a browser and go to that address followed by :3000, for example http://192.168.1.42:3000
If something goes wrong
  • 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.

  1. Keep the server window open. Open another new Command Prompt window and type:
    >npx localtunnel --port 3000

    The first time, it asks Ok to proceed? (y). Type y and press Enter.

  2. It prints a link like https://funny-cats-dance.loca.lt. Send that to your friends.
  3. 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.
You should see
your url is: https://funny-cats-dance.loca.lt
If localtunnel is slow or won't connect

Try Cloudflare's free tunnel instead. Install it once:

>winget install --id Cloudflare.cloudflared

Close that window, open a new one, and run:

>cloudflared tunnel --url http://localhost:3000

Look for a link ending in trycloudflare.com. No password needed.

Good to know

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

  1. Make a free GitHub account at github.com.
  2. Click New repository, name it multiplayer-game, and click Create repository.
  3. Click uploading an existing file and drag in server.js, package.json and package-lock.json. Don't upload the node_modules folder; Render downloads it itself.
  4. Click Commit changes.

Launch it on Render

  1. Sign up to Render at render.com using your GitHub account.
  2. Click New, then Web Service, and choose your multiplayer-game repository.
  3. Set Build command to npm install and Start command to node server.js. Choose the Free instance type.
  4. Click Deploy. After a few minutes you'll get a link ending in onrender.com.
Good to know

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:

Get a bowlMake a new, empty folder for the project.
Add ingredientsInstall the tools with npm.
Write the recipeCreate a server.js file.
Switch the oven onRun it with node server.js.

Node.js only needs installing once, like buying an oven. You don't do Steps 1 and 2 again.

Part 1

A new project on your desktop

We'll make a folder called my-new-game right on your Desktop, so it's easy to find.

  1. Open Command Prompt (Windows key, type cmd, Enter) and go to your Desktop:
    >cd %USERPROFILE%\Desktop

    If it says "The system cannot find the path", your Desktop is kept in OneDrive. Try cd %USERPROFILE%\OneDrive\Desktop instead.

  2. Get a bowl. Make the new folder and step inside it:
    >mkdir my-new-game
    >cd my-new-game

    Pick any name you like, but use small letters and dashes, no spaces. A folder icon will pop up on your Desktop.

  3. Add ingredients. Start the project and install the tools:
    >npm init -y
    >npm install express socket.io

    Every new project needs its own copy of the tools, in its own folder.

  4. Write the recipe. Make the file and paste in the starter code below:
    >notepad server.js

    Click Yes to create the file, paste the code, press Ctrl + S, then close Notepad.

server.jsa tiny starter
// ===== 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));
  1. Switch the oven on.
    >node server.js
  2. Open http://localhost:3000 in two tabs and click the button. Both tabs count up together. That's your new multiplayer project!
You should see
My new project is running on http://localhost:3000
If something goes wrong
  • 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>
Your turn

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.

Part 2

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.

  1. Connect to your server. In Command Prompt on your PC, type this with your server's username and address:
    >ssh yourname@your-server-ip

    Type yes if 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.

  2. Get a bowl. Make the folder in your server home folder and step inside:
    $mkdir my-new-game
    $cd my-new-game
  3. Add ingredients. Same as on your PC:
    $npm init -y
    $npm install express socket.io
  4. Write the recipe. Servers don't have Notepad, so use nano, a simple editor that works inside the window:
    $nano server.js

    Copy the starter code above, then right-click in the window to paste. Save with Ctrl + O then Enter. Leave nano with Ctrl + X.

  5. Switch the oven on.
    $node server.js

    On your PC, open http://your-server-ip:3000 in a browser.

If the page won't load

The server's firewall is probably blocking port 3000. Open it with:

$sudo ufw allow 3000

Some hosting companies (like Oracle or AWS) also have their own firewall on their website where you need to allow port 3000.

Keep it running after you log off

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 pm2
$pm2 start server.js --name my-new-game

If 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-game
>cd my-new-game
>npm init -y
>npm install express socket.io
>notepad server.js
>node server.js

On 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 < 40 under "Aggressive Pigs" and change 40.
  • Walking speed: change baseSpeed: 0.16, speed: 0.16 to a bigger number, like 0.25.
  • Snow: change snowCount = 600 to 2000 for a blizzard.

Next time you want to play

>cd %USERPROFILE%\multiplayer-game
>node server.js

Then open http://localhost:3000.

Use the ← and → arrow keys to move between steps.