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. đ
Top comments (0)