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
- 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
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
- 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 Purposemove
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. ๐
๋ต๊ธ ๋จ๊ธฐ๊ธฐ