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.

Demo multiplayer shooter hry

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 install ti vznikne package-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

  1. Otevři Railway, přihlas se přes GitHub.
  2. New Project → Deploy from GitHub repo → vyber simple-shooter-server.
  3. Pokud Railway sám nenastaví start command, dej npm start.
  4. 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:

  1. Otevři Settings → Pages
  2. Pod Source zvol Deploy from a branch
  3. Branch: main, folder: / (root)
  4. 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 → VariablesNew 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_URL v game.js ukazuje na localhost mí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.

← Blog