Multiplayer 2D shooter — krok za krokem
Tohle je čistý návod. Žádná architektonická teorie, žádné odbočky. Projedeš to shora dolů a budeš mít funkční multiplayer 2D shooter, do kterého se připojí kamarád z jiného počítače.

Co budeš potřebovat
- Node.js ≥ 20 a npm
- Git a účet na GitHubu
- Účet na Railway (zdarma, přihlas se přes GitHub)
- Editor (VS Code, Cursor, cokoliv)
Co budeme stavět
Minimální top-down hru, kde:
- hráč chodí pomocí WASD
- míří myší, kliknutím střílí
- ostatní hráče vidíš v reálném čase
- střely dávají damage, po smrti se respawneš
Frontend pojede na GitHub Pages, backend na Railway, propojené přes Socket.IO.
Struktura projektu
Na konci budeš mít tohle:
simple-shooter/
client/
index.html
style.css
game.js
server/
package.json
server.js
Krok 1: vytvoř složky
V terminálu (Windows: PowerShell nebo cmd) běž tam, kde chceš projekt mít, a spusť:
mkdir simple-shooter
cd simple-shooter
mkdir client
mkdir server
Krok 2: vytvoř server/package.json
Otevři složku server/ a vytvoř soubor package.json:
{
"name": "simple-shooter-server",
"version": "1.0.0",
"description": "Minimal multiplayer shooter server with Socket.IO",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"socket.io": "^4.8.1"
}
}
Krok 3: vytvoř server/server.js
Ve složce server/ vytvoř server.js s tímhle obsahem. Tohle je kompletní herní server — drží pozice hráčů, počítá pohyb, řeší bullets, kolize a damage, a 60× za sekundu posílá stav všem klientům.
const http = require("http");
const { Server } = require("socket.io");
const PORT = process.env.PORT || 3000;
// Po deployi změň FRONTEND_URL na svůj GitHub Pages origin.
// Např. https://tvujucet.github.io
const FRONTEND_URL = process.env.FRONTEND_URL || "http://127.0.0.1:5500";
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Shooter server is running");
});
const io = new Server(server, {
cors: {
origin: FRONTEND_URL,
methods: ["GET", "POST"]
}
});
const WORLD = {
width: 1600,
height: 900
};
const PLAYER_RADIUS = 18;
const BULLET_RADIUS = 5;
const PLAYER_SPEED = 260;
const BULLET_SPEED = 700;
const FIRE_COOLDOWN = 180;
const MAX_HP = 100;
const BULLET_DAMAGE = 25;
const players = {};
const bullets = [];
function randomSpawn() {
return {
x: 100 + Math.random() * (WORLD.width - 200),
y: 100 + Math.random() * (WORLD.height - 200)
};
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function distanceSquared(ax, ay, bx, by) {
const dx = ax - bx;
const dy = ay - by;
return dx * dx + dy * dy;
}
function respawnPlayer(player) {
const spawn = randomSpawn();
player.x = spawn.x;
player.y = spawn.y;
player.hp = MAX_HP;
}
io.on("connection", (socket) => {
const spawn = randomSpawn();
players[socket.id] = {
id: socket.id,
x: spawn.x,
y: spawn.y,
angle: 0,
hp: MAX_HP,
lastShotAt: 0,
input: {
up: false,
down: false,
left: false,
right: false
}
};
socket.emit("init", {
id: socket.id,
world: WORLD
});
socket.on("input", (input) => {
const p = players[socket.id];
if (!p) return;
p.input.up = !!input.up;
p.input.down = !!input.down;
p.input.left = !!input.left;
p.input.right = !!input.right;
if (typeof input.angle === "number") {
p.angle = input.angle;
}
});
socket.on("shoot", ({ angle }) => {
const p = players[socket.id];
if (!p) return;
const now = Date.now();
if (now - p.lastShotAt < FIRE_COOLDOWN) return;
p.lastShotAt = now;
p.angle = angle;
bullets.push({
ownerId: socket.id,
x: p.x + Math.cos(angle) * 24,
y: p.y + Math.sin(angle) * 24,
vx: Math.cos(angle) * BULLET_SPEED,
vy: Math.sin(angle) * BULLET_SPEED,
bornAt: now
});
});
socket.on("disconnect", () => {
delete players[socket.id];
});
});
let lastTime = Date.now();
function gameLoop() {
const now = Date.now();
const dt = (now - lastTime) / 1000;
lastTime = now;
for (const id in players) {
const p = players[id];
let dx = 0;
let dy = 0;
if (p.input.up) dy -= 1;
if (p.input.down) dy += 1;
if (p.input.left) dx -= 1;
if (p.input.right) dx += 1;
const len = Math.hypot(dx, dy) || 1;
dx /= len;
dy /= len;
p.x += dx * PLAYER_SPEED * dt;
p.y += dy * PLAYER_SPEED * dt;
p.x = clamp(p.x, PLAYER_RADIUS, WORLD.width - PLAYER_RADIUS);
p.y = clamp(p.y, PLAYER_RADIUS, WORLD.height - PLAYER_RADIUS);
}
for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i];
b.x += b.vx * dt;
b.y += b.vy * dt;
const tooOld = now - b.bornAt > 1500;
const outside =
b.x < -50 ||
b.y < -50 ||
b.x > WORLD.width + 50 ||
b.y > WORLD.height + 50;
let removed = false;
for (const id in players) {
const p = players[id];
if (id === b.ownerId) continue;
const hitDist = PLAYER_RADIUS + BULLET_RADIUS;
if (distanceSquared(b.x, b.y, p.x, p.y) <= hitDist * hitDist) {
p.hp -= BULLET_DAMAGE;
if (p.hp <= 0) {
respawnPlayer(p);
}
bullets.splice(i, 1);
removed = true;
break;
}
}
if (removed) continue;
if (tooOld || outside) {
bullets.splice(i, 1);
}
}
io.emit("state", {
players,
bullets
});
}
setInterval(gameLoop, 1000 / 60);
server.listen(PORT, "0.0.0.0", () => {
console.log(`Server listening on port ${PORT}`);
console.log(`Allowed frontend origin: ${FRONTEND_URL}`);
});
Krok 4: nainstaluj a spusť backend
Ve složce server/:
cd server
npm install
npm start
V terminálu se objeví Server listening on port 3000. Nech tenhle terminál otevřený — server musí běžet po celou dobu testování.
Tip: Po
npm installti vzniknepackage-lock.json. Až budeš pushovat na GitHub, commitni ho taky — bez něj ti Railway může stáhnout jinou verzi Socket.IO, než má klient.
Krok 5: vytvoř client/index.html
Otevři druhý terminál (server v prvním nech běžet) a běž do client/. Vytvoř index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Simple Multiplayer Shooter</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="ui">
<div>WASD to move</div>
<div>Mouse to aim</div>
<div>Click to shoot</div>
<div id="status">Connecting...</div>
</div>
<canvas id="game"></canvas>
<script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script>
<script src="game.js"></script>
</body>
</html>
Krok 6: vytvoř client/style.css
html, body {
margin: 0;
padding: 0;
background: #111;
overflow: hidden;
font-family: Arial, sans-serif;
color: white;
}
#game {
display: block;
width: 100vw;
height: 100vh;
background: #1b1b1b;
}
#ui {
position: fixed;
left: 12px;
top: 12px;
z-index: 10;
background: rgba(0, 0, 0, 0.45);
padding: 10px 12px;
border-radius: 10px;
line-height: 1.5;
font-size: 14px;
}
Krok 7: vytvoř client/game.js
Tohle je kompletní klient — připojí se na server, sbírá WASD a myš, posílá je serveru, přijímá stav světa a vykresluje hráče i střely.
// Po deployi změň na Railway URL.
// Např. https://moje-hra-production.up.railway.app
const SERVER_URL = "http://localhost:3000";
const socket = io(SERVER_URL, {
transports: ["websocket", "polling"]
});
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const statusEl = document.getElementById("status");
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener("resize", resize);
resize();
let myId = null;
let world = { width: 1600, height: 900 };
let state = {
players: {},
bullets: []
};
const input = {
up: false,
down: false,
left: false,
right: false,
angle: 0
};
const mouse = {
x: 0,
y: 0
};
window.addEventListener("keydown", (e) => {
const key = e.key.toLowerCase();
if (key === "w") input.up = true;
if (key === "s") input.down = true;
if (key === "a") input.left = true;
if (key === "d") input.right = true;
});
window.addEventListener("keyup", (e) => {
const key = e.key.toLowerCase();
if (key === "w") input.up = false;
if (key === "s") input.down = false;
if (key === "a") input.left = false;
if (key === "d") input.right = false;
});
canvas.addEventListener("mousemove", (e) => {
mouse.x = e.clientX;
mouse.y = e.clientY;
});
canvas.addEventListener("mousedown", () => {
socket.emit("shoot", { angle: input.angle });
});
socket.on("connect", () => {
statusEl.textContent = "Connected";
});
socket.on("disconnect", () => {
statusEl.textContent = "Disconnected";
});
socket.on("init", (data) => {
myId = data.id;
world = data.world;
});
socket.on("state", (serverState) => {
state = serverState;
});
function sendInput() {
const me = state.players[myId];
if (me) {
const camera = getCamera(me);
const worldMouseX = mouse.x + camera.x;
const worldMouseY = mouse.y + camera.y;
input.angle = Math.atan2(worldMouseY - me.y, worldMouseX - me.x);
}
socket.emit("input", input);
}
setInterval(sendInput, 1000 / 30);
function getCamera(me) {
const x = me.x - canvas.width / 2;
const y = me.y - canvas.height / 2;
return { x, y };
}
function drawGrid(camera) {
const size = 80;
ctx.strokeStyle = "#2a2a2a";
ctx.lineWidth = 1;
const startX = -((camera.x % size) + size) % size;
const startY = -((camera.y % size) + size) % size;
for (let x = startX; x < canvas.width; x += size) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
for (let y = startY; y < canvas.height; y += size) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();
}
}
function drawWorldBounds(camera) {
ctx.strokeStyle = "#555";
ctx.lineWidth = 4;
ctx.strokeRect(-camera.x, -camera.y, world.width, world.height);
}
function drawPlayer(player, isMe, camera) {
const x = player.x - camera.x;
const y = player.y - camera.y;
ctx.beginPath();
ctx.arc(x, y, 18, 0, Math.PI * 2);
ctx.fillStyle = isMe ? "#4caf50" : "#2196f3";
ctx.fill();
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(
x + Math.cos(player.angle) * 28,
y + Math.sin(player.angle) * 28
);
ctx.strokeStyle = "#f5f5f5";
ctx.lineWidth = 5;
ctx.stroke();
const hpWidth = 40;
const hp = Math.max(0, player.hp) / 100;
ctx.fillStyle = "#400";
ctx.fillRect(x - hpWidth / 2, y - 32, hpWidth, 6);
ctx.fillStyle = "#f44";
ctx.fillRect(x - hpWidth / 2, y - 32, hpWidth * hp, 6);
}
function drawBullet(bullet, camera) {
const x = bullet.x - camera.x;
const y = bullet.y - camera.y;
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fillStyle = "#ffd54f";
ctx.fill();
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const me = state.players[myId];
const camera = me ? getCamera(me) : { x: 0, y: 0 };
drawGrid(camera);
drawWorldBounds(camera);
for (const bullet of state.bullets) {
drawBullet(bullet, camera);
}
for (const id in state.players) {
drawPlayer(state.players[id], id === myId, camera);
}
requestAnimationFrame(render);
}
render();
Krok 8: spusť frontend lokálně
Frontend je statický, ale nesmíš ho otevřít přes file:// — Socket.IO to neumí. Potřebuješ ho servovat přes mini server.
Ve složce client/:
npx serve .
(Když se npx zeptá na instalaci balíčku, potvrď y.)
Otevři URL, kterou ti to vypíše (obvykle http://localhost:3000 nebo http://localhost:5000).
Krok 9: lokální test
Teď bys měl mít:
- první terminál: backend běží na
http://localhost:3000 - druhý terminál: frontend přes
npx serve .
Otevři frontend ve dvou oknech prohlížeče vedle sebe. Měl bys vidět:
- v každém okně dva hráče (zelený jsi ty, modrý je ten druhý)
- pohyb pomocí WASD
- střelbu kliknutím
- damage a respawn
Pokud to funguje, hra je hotová. Zbývá ji jen nasadit na internet.
Test z telefonu nebo z jiného notebooku
Pokud chceš zkusit hru ze druhého zařízení ve stejné Wi-Fi, spusť frontend tak, aby poslouchal na všech rozhraních:
npx serve . -l 0.0.0.0:5000
V game.js musíš dočasně změnit SERVER_URL z localhost na IP svého notebooku (např. http://192.168.1.42:3000), aby ho druhé zařízení vidělo. Backend už na všech rozhraních poslouchá, takže ten měnit nemusíš.
Krok 10: pushni server na GitHub
Na GitHubu si vytvoř nový repozitář, třeba simple-shooter-server. GitHub ti hned po vytvoření ukáže blok s git příkazy („…or push an existing repository from the command line"). Zkopíruj je a spusť ve složce server/:
git init
git remote add origin https://github.com/TVOJ_UCET/simple-shooter-server.git
git branch -M main
git add .
git commit -m "Initial multiplayer shooter server"
git push -u origin main
Krok 11: deploy backendu na Railway
- Otevři Railway, přihlas se přes GitHub.
- New Project → Deploy from GitHub repo → vyber
simple-shooter-server. - Pokud Railway sám nenastaví start command, dej
npm start. - Počkej, až se deploy dokončí. Dostaneš URL, např.
https://simple-shooter-server-production.up.railway.app.
Otevři tu URL v prohlížeči — měl bys vidět text Shooter server is running. Tím víš, že server jede.
Pozor na free tier: Railway zdarma má měsíční limit hodin a server po nečinnosti uspává. První připojení po pauze může trvat pár sekund, než se server probudí.
Krok 12: pushni klient na GitHub
Vytvoř druhý GitHub repo, třeba simple-shooter-client. Příkazy spusť ve složce client/:
git init
git remote add origin https://github.com/TVOJ_UCET/simple-shooter-client.git
git branch -M main
git add .
git commit -m "Initial multiplayer shooter client"
git push -u origin main
Krok 13: zapni GitHub Pages
V GitHub repu klienta:
- Otevři Settings → Pages
- Pod Source zvol Deploy from a branch
- Branch:
main, folder:/ (root) - Klikni Save
Po chvilce GitHub vypíše URL, např. https://tvujucet.github.io/simple-shooter-client/.
Když ji otevřeš teď, hra ještě nebude fungovat — klient se pořád snaží připojit na localhost. To opravíme v dalším kroku.
Krok 14: propoj frontend a backend
Tohle je krok, kde se začátečníci nejčastěji zaseknou. Musíš změnit dvě věci.
14a) V client/game.js přepiš SERVER_URL
Najdi řádku:
const SERVER_URL = "http://localhost:3000";
A přepiš ji na svoji Railway URL:
const SERVER_URL = "https://simple-shooter-server-production.up.railway.app";
Pak změnu commitni a pushni:
git add .
git commit -m "Point client at Railway backend"
git push
GitHub Pages ti to za chvilku znovu nasadí.
14b) Na Railway nastav proměnnou FRONTEND_URL
V Railway projektu klikni na server → Variables → New Variable:
FRONTEND_URL=https://tvujucet.github.io
Důležité: je to origin, ne celá URL stránky. Tedy bez cesty /simple-shooter-client/ a bez lomítka na konci. Jinak server odmítne připojení kvůli CORS.
Po uložení Railway server automaticky redeployne.
Hotovo
Otevři GitHub Pages URL ve dvou prohlížečích (nebo pošli kámošovi). V levém horním rohu se ze stavu „Connecting..." přepne na „Connected" a uvidíte se navzájem.
Když něco nefunguje
„Disconnected" v rohu, hra se nepřipojí. Otevři v prohlížeči DevTools (F12) → záložka Console. Hledej červené chyby. Nejčastější příčiny:
SERVER_URLvgame.jsukazuje nalocalhostmísto Railway URL.- Railway server spí (free tier). Otevři Railway URL přímo v prohlížeči, počkej, až ti vypíše „Shooter server is running", pak refresh hry.
- Railway server vůbec nejede — zkontroluj v Railway logu, jestli se nepadl při startu.
„CORS error" nebo „Access-Control-Allow-Origin".
Na Railway je špatně nastavená proměnná FRONTEND_URL. Musí to být origin GitHub Pages — typicky https://tvujucet.github.io (bez cesty, bez lomítka). Po opravě klikni v Railway na Redeploy.
npm install hlásí chybu.
Zkontroluj, že jsi ve složce server/, ne v root projektu. package.json je jen tam.
Otevírám index.html poklikem a nic se neděje.
Frontend musí běžet přes server, ne přes file://. Spusť npx serve . ve složce client/.
Railway hlásí „No start command".
V Railway projektu otevři Settings → Deploy → Start Command a nastav npm start.
Hra běží, ale střely jdou skrz hráče. Občas jen vizuální dojem — zkontroluj v rohu HP druhého hráče. Pokud klesá, kolize fungují, jen renderování není interpolované.
Co zkusit dál
Až ti hra běží, můžeš ji rozšiřovat:
- jména hráčů a kill counter
- překážky uvnitř mapy
- různé zbraně
- respawn s krátkým delayem a animací
- lobby a více místností
- plynulejší pohyb ostatních hráčů (interpolace mezi snímky stavu ze serveru)
Až narazíš na něco, co v návodu chybí, dej vědět — doplním to.