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 │ ◄────────────────► │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
How It Works
Clients connect to the server via WebSocket
Server tracks all connected players and their positions
Each client sends their movement updates to the server
Server broadcasts all player positions to all clients
Each client renders all players on their screen
🖥️ Step 1: The WebSocket Server (Node.js)
Install Dependencies
npm init -y
npm install ws
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}`);
});
🎮 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>
🎯 Step 3: Running the Game
Start the Server
node server.js
You should see:
🚀 Multiplayer server running on ws://localhost:8080
📊 World size: 2000x2000
Open the Client
- Open multiplayer.html in your browser
- Enter your name when prompted
- Open the same file in another browser tab (or on another device)
- 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;
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;
}
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);
}
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
});
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
});
⚠️ 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
| Resource | Link |
|---|---|
| WebSocket Documentation | developer.mozilla.org/en-US/docs/Web/API/WebSocket |
| ws Library | github.com/websockets/ws |
| Limn Engine | limn-engine-doc.vercel.app |
Draw your game into existence — and let others play it too. 🚀
SOCIAL SHARE CARD GENERATOR