Все эти годы пафосные программисты говорили мне что это совершенно точно невозможно осуществить на только ноде, в браузере и на ts. А я сделяль.

Невозможная игра‑предостережение некоторое время назад была все‑таки дописана в первой играбельной минимальной версии, и вот можно уже тыкать и умирать https://ruinsworld.online/. Не забывайте пить воду, есть, охотиться. От ранений, отравлений и жажды начинает сильно качать. У вас есть союзники — это предки Роботов‑Собутыльников... На самом деле это все рюшечки, а технически решение интересно тем, что это реально эффективно работающая реализация шовного открытого мира и шутера в нем, в бро, только исключительно на ноде и ts. Писал пять лет ручками, такое уже не модно нынче. Мир сейчас развернут на всего 9 локаций (и это число тоже может быть 25 вполне), но одной переменной его можно развернуть в четыре или девять, двадцать пять копий‑процессов по столько локаций — зависит только от мощностей. Вот у меня одно ядро и поэтому только один процесс, один кластер‑биом мира. Больше копий сервера — образуют замкнутую на себя сетку практически никак не связанных «частей мира», между которыми клиенты несущие много своего состояния могут свободно перемещаться. Нагрузка практически не прирастает, только незначительно при подключении новых игроков. Ну вот это и есть, что мы, по сути, построили стабильную модель открытого мира любой величины.

Это вторая и, видимо, последняя статья о многопользовательском браузерном шутере на TypeScript. Сказать, что мне надоело, — это не сказать ничего. Я просто адово упоролся. Но ни о чем не жалею.

Самое главное, о чём нужно рассказать, в заключительном тексте об этой нелепой разработке — это, скажем так — идеология «мультисервера». Осознав реальные ограничения среды и инструментов, которые были сформулированы в ходе долгой работы над проектом (смотрите конец первой статьи), я подумал, и, кажется, реализовал самый простой эффективный способ, получить из уже имеющегося у меня работающего решения на N в квадрате локаций, действительно большой открытый шовный мир — такой, что его размеры зависят только от мощности имеющегося на сервере железа. Получившаяся архитектура максимально гибкая, несвязная, и, наверное, является хорошим примером событийно-ориентированной архитектуры. Решение, в котором одной переменной можем запускать любое количество серверов — копий одного и того же процесса, — я реализовал с помощью менеджера процессов PM2.

Вместо одного сервера на всю игру — сетка серверов, каждый обслуживает свою часть карты. Это позволяет детерменировать и легко масштабировать нагрузку. Wrap‑around (тороидальная топология) — при выходе за границу карты игрок переходит на противоположную сторону — мир «заворачивается» в тор. Каждый сервер может запускаться с определенным «скином» — биомом. Биомы могут получать разные настройки собственной генераци по конфигу, или даже наборы ассетов уже на клиенте.

| MULTIWORLD | Сетка | Серверов | Порты     |
| ---------- | ----- | -------- | --------- |
| 1          | 1×1   | 1        | 3001      |
| 2          | 2×2   | 4        | 3001-3004 |
| 3          | 3×3   | 9        | 3001-3009 |
| 4          | 4×4   | 16       | 3001-3016 |
| 5          | 5×5   | 25       | 3001-3025 |

Формула порта:

BASE_PORT = 3001
port = 3001 + y * width + x

MULTIWORLD=2 (2×2 сетка)

         y=1 (север)
    ┌─────────┬─────────┐
    │  0_1    │  1_1    │
    │ forest  │ town    │
    ├─────────┼─────────┤
    │  0_0    │  1_0    │
    │ town    │ desert  │
    └─────────┴─────────┘
         x=0    x=1
        (запад) (восток)

| SERVER | Координаты (x,y) | Порт | Биом   |
| ------ | ---------------- | ---- | ------ |
| 0_0    | (0, 0)           | 3001 | town   |
| 1_0    | (1, 0)           | 3002 | desert |
| 0_1    | (0, 1)           | 3003 | forest |
| 1_1    | (1, 1)           | 3004 | town   |

Архитектура внутри сервера

Каждый сервер дополнительно делится на локации — игровые области внутри сервера:

| WORLD | Локаций на сервер | Формула       |
| ----- | ----------------- | ------------- |
| 0     | 1×1 = 1           | (0+1)² = 1    |
| 1     | 3×3 = 9           | (1×2+1)² = 9  |
| 2     | 5×5 = 25          | (2×2+1)² = 25 |

Пример: WORLD=1 (3×3 локации на сервере):

                    y=-1 (север)
               ┌──────┬──────┬──────┐
               │(-1,-1)│(0,-1)│(1,-1)│  ← северная граница (выход на другой сервер)
               ├──────┼──────┼──────┤
        y=0    │(-1,0) │(0,0) │(1,0) │  ← центральная локация (спавн новых игроков)
               ├──────┼──────┼──────┤
               │(-1,1) │(0,1) │(1,1) │  ← южная граница (выход на другой сервер)
               └──────┴──────┴──────┘
                    y=1 (юг)

x=-1 (запад) ← ─ ─ ─ ─ ─ ─ ─ → x=1 (восток)

Размер локации сейчас установлен 250 единиц Three.js (метров в игре).

Переменные окружения сервера

| Переменная   | Описание                   | Пример для сервера 0_0 |
| ------------ | -------------------------- | ---------------------- |
| `MULTIWORLD` | Размер сетки (1-5)         | 2                      |
| `SERVER`     | ID сервера (x_y)           | 0_0                    |
| `VIEW`       | Биом (town/forest/desert)  | town                   |
| `PORT`       | Порт (3001 + y\*width + x) | 3001                   |
| `WORLD`      | Размер внутреннего мира    | 1                      |
| `SIZE`       | Размер локации             | 250                    |

Генерация серверов

Код
// server/src/config.ts
const MULTIWORLD = Number(process.env.MULTIWORLD) || 2;
const WIDTH = MULTIWORLD;
const HEIGHT = MULTIWORLD;
const BASE_PORT = 3001;

// Читаем карту из JSON
import * as mapConfig from "../config/map.config.json";

const generateServers = (map: Views[][]): TServer[] => {
  const servers: TServer[] = [];

  for (let y = 0; y < map.length; y++) {
    for (let x = 0; x < map[y].length; x++) {
      servers.push({
        id: `${x}_${y}`, // "0_0", "1_0" и т.д.
        port: BASE_PORT + y * WIDTH + x, // 3001 + y*2 + x
        view: map[y][x], // town/desert/forest
        top: getNeighborId(x, y, "top"),
        bottom: getNeighborId(x, y, "bottom"),
        left: getNeighborId(x, y, "left"),
        right: getNeighborId(x, y, "right"),
      });
    }
  }

  return servers;
};

// Вычисление соседей с wrap-around
const getNeighborId = (x: number, y: number, direction: string): string => {
  let newX = x;
  let newY = y;

  switch (direction) {
    case "top":
      newY = (y - 1 + HEIGHT) % HEIGHT;
      break;
    case "bottom":
      newY = (y + 1) % HEIGHT;
      break;
    case "left":
      newX = (x - 1 + WIDTH) % WIDTH;
      break;
    case "right":
      newX = (x + 1) % WIDTH;
      break;
  }

  return `${newX}_${newY}`;
};

Запуск: PM2 + ecosystem.config.js

Для запуска всех серверов используется PM2 с динамической конфигурацией:

Код
// ecosystem.config.js
module.exports = {
  apps: Array.from({ length: MULTIWORLD * MULTIWORLD }, (_, i) => {
    const x = i % MULTIWORLD;
    const y = Math.floor(i / MULTIWORLD);
    const serverId = `${x}_${y}`;
    const port = BASE_PORT + y * MULTIWORLD + x;

    return {
      name: `ruinsworld-${serverId}`,
      script: "dist/main.js",
      env: {
        NODE_ENV: "production",
        MULTIWORLD: MULTIWORLD.toString(),
        SERVER: serverId,
        VIEW: map[y][x],
        PORT: port.toString(),
        WORLD: (process.env.WORLD || "1").toString(),
        SIZE: (process.env.SIZE || "250").toString(),
      },
    };
  }),
};
  1. Игрок выходит за границу локации (x = -1 при WORLD=1)

  2. Клиент вычисляет соседний сервер через /config API

  3. Клиент переключает socket на новый порт

  4. Клиент отправляет событие релокации старому серверу

  5. Клиент делает window.location.reload()

  6. Новый сервер принимает игрока на новой локации

И вот тут можно рассказать о интересном. Можете себе представить, как мне было непросто тестировать переходы? Если каждый сервер у нас развёрнут на 9 локаций, то для того, чтобы добежать до соседнего сервера, нужно пройти полторы большие локации. Тестировать такой переход приходилось очень много раз — и вот тут действительно выручает то, что всё решение дотошно гибко и аккуратно написано: оно полностью опирается на базовые числа, и одной переменной можно изменить размер локации, количество локаций в сервере, отключить врагов, чтобы проще было бежать к горизонту и мы не получали урон... Ну, вот так вот я и выкручивался.

Серверы практически ничего не знают друг о друге. Единственное, что объединяет их — общий конфиг.

// /config API возвращает:
{
  "servers": [
    {
      "id": "0_0",
      "port": 3001,
      "view": "town",
      "top": "0_1",
      "bottom": "0_0",
      "left": "1_0",
      "right": "1_0"
    },
    // ... другие серверы
  ]
}

Каждый сервер знает только:

  • Свой ID и порт

  • Какой биом (town, forest, desert)

  • ID соседей (top, bottom, left, right)

Серверы не общаются между собой напрямую.

Полный цикл релокации (между серверами)

1. КЛИЕНТ          → доходит до края карты
2. КЛИЕНТ          → вычисляет направление (top/bottom/left/right)
3. КЛИЕНТ          → смотрит в конфиг: server[direction] → ID целевого сервера
4. КЛИЕНТ          → вычисляет новые координаты (newX, newY) на основе направления
5. КЛИЕНТ → СЕРВЕР → отправляет: relocationServer { id, location, newX, newY }
6. СЕРВЕР          → удаляет игрока (НЕ отправляет ответ!)
7. КЛИЕНТ          → setServer(newPort) — переключает socket на новый сервер
8. КЛИЕНТ          → emitter.emit(EmitterEvents.relocationServer) — отправляет на СТАРЫЙ сервер
9. КЛИЕНТ          → window.location.reload() — перезагрузка
10. НОВЫЙ СЕРВЕР   → получает onConnect с message.relocation = { user, newX, newY, location }
11. НОВЫЙ СЕРВЕР   → вычисляет newLocationId = getLocationIdByCoords(newX, newY)
12. НОВЫЙ СЕРВЕР   → добавляет игрока на локацию, отправляет onEnter

При переходе между серверами координаты инвертируются для того чтобы сохранить «направление» движения.

Код на сервере
// game.ts
public onRelocationServer(message: IUpdateMessage): void {
  console.log('[GAME onRelocationServer] START');
  this._user = this.users.onRelocationServer(this._self, message);
  this.world.onRelocationServer(message);
  // ВАЖНО: ничего не отправляется клиенту!
  // Клиент уже сделал reload и подключается к новому серверу
}

// world.ts
public onRelocationServer(message: IUpdateMessage): void {
  if (message && message.id && message.location) {
    this.removeUnitFromLocation(message.id, message.location, Fields.users);
  }
}
Код на клиенте
// В Hero.ts используется cooldown
if (!this._noEvent.includes("relocationServer")) {
  this._setNoEvent("relocationServer", 500);
  // ... выполняем релокацию
}

Перезагрузка — это нормально

И тут мы опять возвращаемся к теме, которая важной смысловой линией проходит через все мои статьи об веб‑играх — кажется она критически важна для понимания правильной работы хорошего браузерного интерфейса, и, в том или ином виде, встречается повсеместно на клиентской стороне, в современных хороших практиках работы с поведением веб‑страниц — вы должны думать о перезагрузке, прежде всего. Нам очень часто нужно сохранять и восстанавливать что‑нибудь — то есть пробрасывать состояние через перезагрузку — умение адекватно обрабатывать такие сценарии критически важно. В данном случае, игра должна начаться, условно, «с того же самого места». Хотя если перезагрузка произошла случайно, вполне вероятно, что за это время игрок может получить урон от противников и погибнуть.

Но даже больше — RUINSWORLD это браузерный рогалик в котором перезагрузка страницы является именно штатным механизмом работы. Вкладка может быть закрыта/перезагружена в любой момент пользователем или системой (нехватка памяти, сон устройства). Сеть может быть нестабильна. Или разные биомы могут содержать разные ассеты (текстуры, 3D‑модели, звуки) — намного проще загрузить новые, чем управлять переключением (сомневаюсь что вообще возможно реализовать корректно в бро c Three). Меньше багов, проще отладка, чище код — нет необходимости синхронизировать сложное состояние между локациями. Это НЕ баг — это фича! Не нужно бороться с браузерной средой — нужно принять её.

  1. Игрок переходит на новую локацию (доходит до границы)

  2. Клиент получает onRelocation с location.id

  3. Клиент вызывает getLocation(location.id) → загружает данные локации

  4. Срабатывает watch(locationData) → world.init(self)

  5. Мир полностью пересоздаётся с новыми ассетами

  6. Игрок появляется на новой локации

    Сценарии перезагрузки:

  • Переход между серверами: игрок дошёл до границы сервера → reload → подключение к новому серверу

  • Переход между локациями: игрок дошёл до границы локации (x = ±WORLD) → внутренняя релокация (wrap‑around)

  • Разрыв соединения: сеть оборвалась → перезагрузка → переподключение

  • Закрытие вкладки: игрок случайно закрыл → переоткрытие → вход заново

Расовая война на руинах: геноциды и эпидемии

В первой статье я рассказывал об оптимизационных механиках, которые ввел для того, чтобы улучшить производительность своей открытой веб‑вселенной. Например, NPC на сервере в локациях, на которых нет игроков — замедляются, время там течет в 10 раз медленнее, и алгоритм динамически выбирает из массива NPC небольшое количество, которое необходимо в этом кадре немного «продвинуть». Потому что если они будут «совсем замирать», это крайне негативно скажется на игровом процессе. Они всё‑таки должны медленно продвигаться по своей непростой жизни. Так вот, очень интересно: для хорошего баланса параметров различных игровых рас мне пришлось придумать еще и механику эпидемии. Параметры игровых рас отвечают за совершенно разные вещи — например, интеллект у меня отвечает за то, насколько велик угол обзора у данного вида неигровых персонажей. И сбалансировать это очень сложно — всё равно какая‑нибудь раса начинает доминировать и геноцидить неудачников. И тогда я придумал эпидемии. Это ленивая проверка на сервере, которая очень редко пересчитывает количество особей данной расы, и если их становится слишком много — просто косит самых старых и опытных. В игре у каждого юнита с ходом временем увеличивается опыт, который влияет на его боевые качества (циферка над головой).

Слишком быстрые пули

Или вот ещё полезное. Я столкнулся со стандартной проблемой в шутерах. Быстрые пули проскакивали сквозь врагов. Обычный кастинг, даже «с подшагами» — не гарантировал обнаружение. И вот вам решение. Вместо точечного raycast — для надежной фиксации попаданий быстролетящих выстрелов нужно использовать swept‑sphere (капсула от prevPos до currPos с радиусом снаряда). Проверяется вся траектория снаряда за кадр, а не только конечная точка.

Bullets.ts
// Предыдущая позиция пули
const prevPos = this._shotClone.position.clone();

// Текущая позиция пули (после движения за этот кадр)
const currPos = prevPos
  .clone()
  .add(this._velocity.clone().multiplyScalar(self.events.delta * DESIGN.GAMEPLAY.SHOTS_SPEED * speedMult));

// Swept-sphere для винтовки (капсула от prevPos до currPos)
if (weapon === WeaponType.Rifle) {
  const bulletSize = 0.08; // радиус пули

  // Проверяем столкновение с миром (стены, пол)
  this._result = self.octree.sweptSphereIntersect(prevPos, currPos, bulletSize);

  // Проверяем столкновение с врагами
  this._result2 = self.octree3.sweptSphereIntersect(prevPos, currPos, bulletSize);

  // Проверяем столкновение с дверями
  this._result4 = self.octree4.sweptSphereIntersect(prevPos, currPos, bulletSize);
}
Octree.ts
// Swept-sphere: сфера радиуса radius, движущаяся от start до end
public sweptSphereIntersect(
  start: Vector3,
  end: Vector3,
  radius: number,
): TResult {
  // Создаём капсулу от стартовой до конечной точки
  const capsule = new Capsule(start, end, radius);

  // Находим все треугольники, которые может пересечь капсула
  const triangles: Triangle[] = [];
  this.getCapsuleTriangles(capsule, triangles);

  // Для каждого треугольника проверяем пересечение с капсулой
  let minDistance = Infinity;
  let bestResult: TResult | null = null;

  for (let i = 0; i < triangles.length; i++) {
    const result = this._triangleCapsuleIntersect(capsule, triangles[i]);
    if (result) {
      const point = (result as any).point;
      if (point) {
        const dist = point.distanceTo(start);
        if (dist < minDistance) {
          minDistance = dist;
          bestResult = result;
        }
      }
    }
  }

  if (bestResult) {
    return {
      point: (bestResult as any).point,
      normal: bestResult.normal,
      depth: minDistance,
    };
  }

  return false;
}
Проверка пересечения капсулы с треугольником
private _triangleCapsuleIntersect(capsule: Capsule, triangle: Triangle): TResult {
  triangle.getPlane(this._plane);

  // Проверяем, может ли капсула пересечь треугольник
  let d1 = this._plane.distanceToPoint(capsule.start) - capsule.radius;
  let d2 = this._plane.distanceToPoint(capsule.end) - capsule.radius;

  // Если обе точки сферы по одну сторону плоскости — нет пересечения
  if ((d1 > 0 && d2 > 0) || (d1 < -capsule.radius && d2 < -capsule.radius))
    return false;

  // Находим точку пересечения и проверяем внутри треугольника
  // ... также проверяем рёбра треугольника (line1-line2 пересечения)
}

Для гранат используется пятикратно увеличенный радиус для учёта параболической траектории. И первые пять метров от точки вылета — зона безопасности (не проверяем мир и двери, чтобы не взрываться об стены рядом с игроком).

Нестандартные подходы для нестандартных задач!

Какой вывод можно сделать из всей этой истории с написанием странной игры в которую никто, вероятно, даже не будет играть? Невозможное — возможно. Главное — очень хотеть и стараться. При решении нестандартных задач эффективными оказываются именно нестандартные подходы. В моих реализациях, в принципе, нет ничего прямо революционного — просто некоторые «общепринятые» вещи, которые мы уже привыкли воспринимать определённым образом, были немного отодвинуты в сторону, и заменены вполне достаточными в данном случае по геймплею простыми решениями. Об этом много рассказывалось в первой статье. Например, то, что клиент сам считает свои выстрелы, позволяет очень мощно разгрузить сервер. И после того, как была решена проблема с пропуском попаданий — игровой процесс при этом остаётся достаточно динамичным, минимальная пиф‑паф боевка не страдает от того, что у нас нет классического для шутеров тяжелого сервера как единственного источника истины, который делает и хранит слепки состояния и так далее...

Конечно, сейчас в игре делать практически нечего. Можно, прикрываясь сильными союзниками, пытаться валить опытных врагов и лутать их трупы — за счёт этого круто качаться. Ну, или шариться и щемится на стелсе пытаясь найти и заслушать потёртый диск Гражданской Обороны. Конечно, можно было бы скормить всё это LLM и допилить еще предметы, какие-то квесты... Но, мне кажется, смысл понятен. А я увлёкся созданием уже БЕСШОВНОГО открытого мира на Godot.

Тестируем и пишем мнения в комментариях к этой статье: https://ruinsworld.online/