DEV Community

Cover image for Multiplayer Games with Limn Engine and WebSockets
Kehinde Owolabi
Kehinde Owolabi

Posted on

Multiplayer Games with Limn Engine and WebSockets

Multiplayer Games with Limn Engine and WebSockets

Building Real-Time Multiplayer Games from Scratch


📖 Introduction

Multiplayer gaming is one of the most sought-after features in game development. Players want to compete, cooperate, and connect with others. But building a multiplayer game is notoriously difficult — networking introduces latency, synchronization issues, and a whole new layer of complexity.

In this guide, we'll build a real-time multiplayer game using Limn Engine and WebSockets. We'll cover everything from setting up a WebSocket server to handling player movement, synchronization, and latency compensation.

Note: This is a basic implementation for learning purposes. For production games, you'll need to add authentication, matchmaking, and more robust error handling.


đŸŽ¯ What We're Building

A simple top-down multiplayer game where:

  • Players connect to a server
  • Each player controls a colored square
  • Players see each other move in real-time
  • Players can see each other's names

đŸ—ī¸ Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│                                                                 │
│   ┌─────────────┐     WebSocket      ┌─────────────────────┐   │
│   │   Client 1  │ ◄────────────────â–ē │                     │   │
│   └─────────────┘                     │                     │   │
│                                        │   Node.js Server   │   │
│   ┌─────────────┐     WebSocket      │   (ws library)     │   │
│   │   Client 2  │ ◄────────────────â–ē │                     │   │
│   └─────────────┘                     └─────────────────────┘   │
│                                                                 │
│   ┌─────────────┐                                              │
│   │   Client 3  │ ◄────────────────â–ē                         │
│   └─────────────┘                                              │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

How It Works

  1. Clients connect to the server via WebSocket
  2. Server tracks all connected players and their positions
  3. Each client sends their movement updates to the server
  4. Server broadcasts all player positions to all clients
  5. Each client renders all players on their screen

đŸ–Ĩī¸ Step 1: The WebSocket Server (Node.js)

Install Dependencies

npm init -y
npm install ws
Enter fullscreen mode Exit fullscreen mode

Server Code (server.js)

const WebSocket = require('ws');
const http = require('http');

// ── CONFIGURATION ──
const PORT = 8080;
const WORLD_WIDTH = 2000;
const WORLD_HEIGHT = 2000;

// ── CREATE HTTP SERVER ──
const server = http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.end('<h1>Multiplayer Server Running</h1>');
});

// ── CREATE WEBSOCKET SERVER ──
const wss = new WebSocket.Server({ server });

// ── GAME STATE ──
const players = {};
let nextId = 1;

// ── HELPER FUNCTIONS ──
function broadcast(data) {
    const message = JSON.stringify(data);
    wss.clients.forEach(client => {
        if (client.readyState === WebSocket.OPEN) {
            client.send(message);
        }
    });
}

function getPlayerState(id) {
    return {
        id: id,
        x: players[id].x,
        y: players[id].y,
        color: players[id].color,
        name: players[id].name
    };
}

function getAllPlayerStates() {
    const states = {};
    for (let id in players) {
        states[id] = getPlayerState(id);
    }
    return states;
}

// ── WEBHOOKS ──
wss.on('connection', (ws) => {
    console.log('New player connected');

    // Assign player ID
    const playerId = nextId++;

    // Assign random color
    const colors = ['#ff6b6b', '#5b8cff', '#7fffb2', '#ffbb6b', '#ff6bff', '#6bffb2'];
    const color = colors[Math.floor(Math.random() * colors.length)];

    // Create player
    players[playerId] = {
        id: playerId,
        x: Math.random() * 400 + 200,
        y: Math.random() * 400 + 200,
        color: color,
        name: `Player ${playerId}`
    };

    // Send current state to new player
    ws.send(JSON.stringify({
        type: 'init',
        playerId: playerId,
        players: getAllPlayerStates()
    }));

    // Broadcast new player to others
    broadcast({
        type: 'playerJoined',
        player: getPlayerState(playerId)
    });

    // ── HANDLE INCOMING MESSAGES ──
    ws.on('message', (message) => {
        try {
            const data = JSON.parse(message);

            if (data.type === 'move') {
                // Update player position
                players[playerId].x = data.x;
                players[playerId].y = data.y;

                // Broadcast update to all clients
                broadcast({
                    type: 'playerMoved',
                    id: playerId,
                    x: data.x,
                    y: data.y
                });
            }

            if (data.type === 'chat') {
                broadcast({
                    type: 'chat',
                    id: playerId,
                    name: players[playerId].name,
                    message: data.message
                });
            }

            if (data.type === 'nameChange') {
                players[playerId].name = data.name;
                broadcast({
                    type: 'playerNameChanged',
                    id: playerId,
                    name: data.name
                });
            }
        } catch (e) {
            console.error('Error processing message:', e);
        }
    });

    // ── HANDLE DISCONNECT ──
    ws.on('close', () => {
        console.log(`Player ${playerId} disconnected`);
        delete players[playerId];
        broadcast({
            type: 'playerLeft',
            id: playerId
        });
    });
});

// ── START SERVER ──
server.listen(PORT, () => {
    console.log(`🚀 Multiplayer server running on ws://localhost:${PORT}`);
    console.log(`📊 World size: ${WORLD_WIDTH}x${WORLD_HEIGHT}`);
});
Enter fullscreen mode Exit fullscreen mode

🎮 Step 2: The Client (Limn Engine)

Client Code (multiplayer.html)

<!DOCTYPE html>
<html>
<head>
    <title>Multiplayer Game - Limn Engine</title>
    <script src="epic.js"></script>
    <style>
        body { margin: 0; background: #0a0a0a; overflow: hidden; }
        #status {
            position: fixed;
            top: 10px;
            left: 50%;
            transform: translateX(-50%);
            color: #7fffb2;
            font-family: 'Courier New', monospace;
            font-size: 14px;
            background: rgba(0,0,0,0.7);
            padding: 8px 16px;
            border-radius: 8px;
            z-index: 1000;
            pointer-events: none;
        }
        #chat {
            position: fixed;
            bottom: 20px;
            left: 50%;
            transform: translateX(-50%);
            width: 400px;
            z-index: 1000;
        }
        #chat-input {
            width: 100%;
            padding: 8px 12px;
            background: rgba(0,0,0,0.8);
            color: white;
            border: 1px solid #333;
            border-radius: 8px;
            font-size: 14px;
            outline: none;
            display: none;
        }
        #chat-input:focus {
            border-color: #7fffb2;
        }
        #chat-messages {
            max-height: 150px;
            overflow-y: auto;
            margin-bottom: 8px;
            color: #aaa;
            font-size: 12px;
            font-family: 'Courier New', monospace;
        }
        #chat-messages .system { color: #7fffb2; }
        #chat-messages .player { color: #5b8cff; }
    </style>
</head>
<body>

    <div id="status">🔌 Connecting...</div>

    <div id="chat">
        <div id="chat-messages"></div>
        <input id="chat-input" type="text" placeholder="Press Enter to chat..." />
    </div>

    <script>
        // ── DOM REFS ──
        const statusEl = document.getElementById('status');
        const chatInput = document.getElementById('chat-input');
        const chatMessages = document.getElementById('chat-messages');

        // ── LIMN ENGINE SETUP ──
        const display = new Display();
        display.perform();
        display.start(800, 600);
        display.backgroundColor("#0a0a10");

        // World size
        display.camera.worldWidth = 2000;
        display.camera.worldHeight = 2000;

        // ── GAME STATE ──
        let myId = null;
        let myName = 'Player';
        let remotePlayers = {};
        let localPlayer = null;
        let isConnected = false;

        // ── CREATE LOCAL PLAYER ──
        function createLocalPlayer() {
            localPlayer = new Component(36, 36, "cyan", 400, 300, "rect");
            display.add(localPlayer);
            display.camera.follow(localPlayer, true);
            return localPlayer;
        }

        // ── REMOTE PLAYER MANAGEMENT ──
        function addRemotePlayer(id, x, y, color, name) {
            const player = new Component(36, 36, color, x, y, "rect");
            display.add(player);

            // Store extra data
            player._id = id;
            player._name = name || `Player ${id}`;
            player._targetX = x;
            player._targetY = y;

            remotePlayers[id] = player;
            return player;
        }

        function removeRemotePlayer(id) {
            if (remotePlayers[id]) {
                remotePlayers[id].destroy();
                delete remotePlayers[id];
            }
        }

        function updateRemotePlayer(id, x, y) {
            if (remotePlayers[id]) {
                // Smooth interpolation (lerp) for movement
                remotePlayers[id]._targetX = x;
                remotePlayers[id]._targetY = y;
            }
        }

        // ── WEBHOOKS SETUP ──
        function connectWebSocket() {
            const ws = new WebSocket('ws://localhost:8080');

            ws.onopen = () => {
                isConnected = true;
                statusEl.textContent = '✅ Connected!';
                statusEl.style.color = '#7fffb2';

                // Create local player after receiving initial state
                createLocalPlayer();
            };

            ws.onmessage = (event) => {
                try {
                    const data = JSON.parse(event.data);
                    handleMessage(data);
                } catch (e) {
                    console.error('Error parsing message:', e);
                }
            };

            ws.onclose = () => {
                isConnected = false;
                statusEl.textContent = '❌ Disconnected!';
                statusEl.style.color = '#ff6b6b';
                setTimeout(() => {
                    connectWebSocket(); // Auto-reconnect
                }, 3000);
            };

            ws.onerror = (error) => {
                console.error('WebSocket error:', error);
                statusEl.textContent = 'âš ī¸ Connection error';
                statusEl.style.color = '#ffbb6b';
            };

            // ── MESSAGE HANDLER ──
            function handleMessage(data) {
                switch (data.type) {
                    case 'init':
                        // Set player ID
                        myId = data.playerId;
                        statusEl.textContent = `✅ Connected as ${myName}`;

                        // Add remote players
                        for (let id in data.players) {
                            if (parseInt(id) !== myId) {
                                const p = data.players[id];
                                addRemotePlayer(id, p.x, p.y, p.color, p.name);
                            }
                        }
                        break;

                    case 'playerJoined':
                        if (data.player.id !== myId) {
                            const p = data.player;
                            addRemotePlayer(p.id, p.x, p.y, p.color, p.name);
                            addChatMessage(`👋 ${p.name} joined the game`, 'system');
                        }
                        break;

                    case 'playerLeft':
                        if (data.id !== myId) {
                            const name = remotePlayers[data.id]?._name || `Player ${data.id}`;
                            removeRemotePlayer(data.id);
                            addChatMessage(`👋 ${name} left the game`, 'system');
                        }
                        break;

                    case 'playerMoved':
                        if (data.id !== myId) {
                            updateRemotePlayer(data.id, data.x, data.y);
                        }
                        break;

                    case 'playerNameChanged':
                        if (data.id !== myId) {
                            if (remotePlayers[data.id]) {
                                remotePlayers[data.id]._name = data.name;
                            }
                        }
                        break;

                    case 'chat':
                        if (data.id !== myId) {
                            addChatMessage(`${data.name}: ${data.message}`, 'player');
                        }
                        break;
                }
            }

            // ── SEND FUNCTIONS ──
            window.sendMove = function(x, y) {
                if (isConnected && ws.readyState === WebSocket.OPEN) {
                    ws.send(JSON.stringify({
                        type: 'move',
                        x: x,
                        y: y
                    }));
                }
            };

            window.sendChat = function(message) {
                if (isConnected && ws.readyState === WebSocket.OPEN) {
                    ws.send(JSON.stringify({
                        type: 'chat',
                        message: message
                    }));
                }
            };

            window.sendNameChange = function(name) {
                if (isConnected && ws.readyState === WebSocket.OPEN) {
                    ws.send(JSON.stringify({
                        type: 'nameChange',
                        name: name
                    }));
                }
            };

            // ── CHAT UI ──
            function addChatMessage(text, type = 'system') {
                const div = document.createElement('div');
                div.className = type;
                div.textContent = text;
                chatMessages.appendChild(div);
                chatMessages.scrollTop = chatMessages.scrollHeight;

                // Limit messages
                while (chatMessages.children.length > 50) {
                    chatMessages.removeChild(chatMessages.firstChild);
                }
            }

            // ── CHAT INPUT HANDLING ──
            document.addEventListener('keydown', (e) => {
                if (e.key === 't' || e.key === 'T') {
                    chatInput.style.display = 'block';
                    chatInput.focus();
                }
                if (e.key === 'Enter' && chatInput.style.display === 'block') {
                    const msg = chatInput.value.trim();
                    if (msg) {
                        if (msg.startsWith('/name ')) {
                            const newName = msg.substring(6).trim();
                            if (newName) {
                                myName = newName;
                                sendNameChange(newName);
                                addChatMessage(`You changed your name to ${newName}`, 'system');
                            }
                        } else {
                            sendChat(msg);
                            addChatMessage(`${myName}: ${msg}`, 'player');
                        }
                    }
                    chatInput.value = '';
                    chatInput.style.display = 'none';
                }
                if (e.key === 'Escape' && chatInput.style.display === 'block') {
                    chatInput.value = '';
                    chatInput.style.display = 'none';
                }
            });

            // ── PLAYER NAME INPUT ──
            const nameInput = prompt('Enter your name:', `Player ${Math.floor(Math.random() * 1000)}`);
            if (nameInput) {
                myName = nameInput;
                // We'll send the name after connection is established
            }

            // Wait for connection to send name
            const nameCheckInterval = setInterval(() => {
                if (isConnected && ws.readyState === WebSocket.OPEN) {
                    sendNameChange(myName);
                    clearInterval(nameCheckInterval);
                }
            }, 100);

            // ── GAME LOOP ──
            const SPEED = 200;
            let lastSendTime = 0;
            const SEND_INTERVAL = 50; // ms

            function update(dt) {
                if (!isConnected || !localPlayer) return;

                // Player movement (WASD)
                let mx = 0, my = 0;
                if (display.keys[87]) my = -1;
                if (display.keys[83]) my = 1;
                if (display.keys[65]) mx = -1;
                if (display.keys[68]) mx = 1;

                if (mx !== 0 && my !== 0) {
                    mx *= 0.707;
                    my *= 0.707;
                }

                localPlayer.x += mx * SPEED * dt;
                localPlayer.y += my * SPEED * dt;

                // Clamp to world
                localPlayer.x = Math.max(0, Math.min(1950, localPlayer.x));
                localPlayer.y = Math.max(0, Math.min(1950, localPlayer.y));

                // Send position to server (throttled)
                const now = performance.now();
                if (now - lastSendTime > SEND_INTERVAL) {
                    sendMove(localPlayer.x, localPlayer.y);
                    lastSendTime = now;
                }

                // Update remote players (smooth interpolation)
                for (let id in remotePlayers) {
                    const p = remotePlayers[id];
                    // Smoothly interpolate toward target position
                    p.x += (p._targetX - p.x) * 0.15;
                    p.y += (p._targetY - p.y) * 0.15;
                }
            }

            // ── START THE GAME ──
            // The update function is now ready
        }

        // ── INITIALIZE CONNECTION ──
        connectWebSocket();

        // ── OVERRIDE UPDATE ──
        // This is a bit of a hack — we're replacing the global update function
        // that Limn Engine expects. In a real game, you'd structure this better.
        const originalUpdate = window.update || function() {};
        window.update = function(dt) {
            // The actual update logic is inside connectWebSocket
            // but we need to call it from here
            if (typeof updateGame === 'function') {
                updateGame(dt);
            }
        };

        // We'll store the update function from the closure
        // This is a simplified approach — in production, use a proper module system
        let updateGame = function(dt) {};

        // Override the connectWebSocket to expose the update function
        const originalConnect = connectWebSocket;
        connectWebSocket = function() {
            // Call the original but capture the update function
            // This is a hack — in a real game, use proper architecture
            originalConnect();

            // Wait for connection to be established
            const checkInterval = setInterval(() => {
                if (isConnected) {
                    // Expose the update function
                    updateGame = function(dt) {
                        if (!isConnected || !localPlayer) return;

                        // Player movement (WASD)
                        let mx = 0, my = 0;
                        if (display.keys[87]) my = -1;
                        if (display.keys[83]) my = 1;
                        if (display.keys[65]) mx = -1;
                        if (display.keys[68]) mx = 1;

                        if (mx !== 0 && my !== 0) {
                            mx *= 0.707;
                            my *= 0.707;
                        }

                        localPlayer.x += mx * SPEED * dt;
                        localPlayer.y += my * SPEED * dt;

                        // Clamp to world
                        localPlayer.x = Math.max(0, Math.min(1950, localPlayer.x));
                        localPlayer.y = Math.max(0, Math.min(1950, localPlayer.y));

                        // Send position to server (throttled)
                        const now = performance.now();
                        if (now - lastSendTime > SEND_INTERVAL) {
                            sendMove(localPlayer.x, localPlayer.y);
                            lastSendTime = now;
                        }

                        // Update remote players (smooth interpolation)
                        for (let id in remotePlayers) {
                            const p = remotePlayers[id];
                            p.x += (p._targetX - p.x) * 0.15;
                            p.y += (p._targetY - p.y) * 0.15;
                        }
                    };
                    clearInterval(checkInterval);
                }
            }, 100);
        };

        // Actually start the connection
        connectWebSocket();

        console.log('🎮 Multiplayer client ready');
        console.log('📝 Type /name YourName to change name');
        console.log('đŸ’Ŧ Press T to chat');
        console.log('đŸ•šī¸  WASD to move');
    </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

đŸŽ¯ Step 3: Running the Game

Start the Server

node server.js
Enter fullscreen mode Exit fullscreen mode

You should see:

🚀 Multiplayer server running on ws://localhost:8080
📊 World size: 2000x2000
Enter fullscreen mode Exit fullscreen mode

Open the Client

  1. Open multiplayer.html in your browser
  2. Enter your name when prompted
  3. Open the same file in another browser tab (or on another device)
  4. Watch both players move in real-time!

📊 Key Concepts Explained

1. WebSocket Communication

Message Type Sent By Received By Purpose
move Client Server Send player position
playerMoved Server All Clients Broadcast position update
playerJoined Server All Clients New player joined
playerLeft Server All Clients Player disconnected

2. Interpolation (Smooth Movement)

// Smoothly interpolate toward target position
p.x += (p._targetX - p.x) * 0.15;
p.y += (p._targetY - p.y) * 0.15;
Enter fullscreen mode Exit fullscreen mode

This makes remote players move smoothly instead of jumping.

3. Throttled Sending

const SEND_INTERVAL = 50; // ms
if (now - lastSendTime > SEND_INTERVAL) {
    sendMove(localPlayer.x, localPlayer.y);
    lastSendTime = now;
}
Enter fullscreen mode Exit fullscreen mode

Sending every frame (60 times/sec) would overload the server. Sending 20 times/sec (every 50ms) is enough.


🚀 Extending the Game

Add Player Names Above Heads

// In the render loop
for (let id in remotePlayers) {
    const p = remotePlayers[id];
    // Draw name above player
    display.context.fillStyle = "white";
    display.context.font = "12px Arial";
    display.context.textAlign = "center";
    display.context.fillText(p._name, p.x + 18, p.y - 10);
}
Enter fullscreen mode Exit fullscreen mode

Add Shooting

// Client sends shoot message
function shoot() {
    ws.send(JSON.stringify({
        type: 'shoot',
        x: localPlayer.x + 18,
        y: localPlayer.y + 18,
        angle: localPlayer.angle || 0
    }));
}

// Server broadcasts bullet
broadcast({
    type: 'bulletFired',
    id: playerId,
    x: data.x,
    y: data.y,
    angle: data.angle
});
Enter fullscreen mode Exit fullscreen mode

Add Score Tracking

// Server tracks scores
players[playerId].score = 0;

// On hit
players[shooterId].score += 10;
broadcast({
    type: 'scoreUpdate',
    id: shooterId,
    score: players[shooterId].score
});
Enter fullscreen mode Exit fullscreen mode

âš ī¸ Limitations (Not Yet Tested)

Limitation Description
No authentication Anyone can join with any name
No matchmaking All players join the same world
No room support Can't create separate game rooms
No latency compensation Players with high ping will lag
No reconnection handling If connection drops, progress is lost

đŸŽ¯ The One-Line Summary

"WebSockets + Limn Engine = real-time multiplayer games with minimal setup."


🔗 Resources


Draw your game into existence — and let others play it too. 🚀

Top comments (0)