Игры редко пишут по чистой архитектуре, и на то есть причины: обычно игровой объект сам держит меш, сам дёргает физическое тело и сам решает правила. Так быстрее, и в геймдеве это норма, а не грех.

Я в своём браузерном платформере поступил иначе: правила игры не знают ни про Three.js, ни про Rapier, ни про DOM. Ниже - во что это обошлось, и что дало взамен.

Спойлер: главное, что дало, - тесты, которые проходят за секунды и не зависят от того, насколько медленно на конкретной машине рисуется кадр.

Игра
Игра

Вот что получается на выходе. Ниже - почему код под этим устроен именно так.

Слои
Слои

Стрелки смотрят внутрь. Домен сидит в центре и принципиально ни на кого не оглядывается.

Одно правило, на котором всё держится

Оно жёсткое и без исключений: в src/domain запрещены импорты three, rapier, DOM API и любых адаптеров. Только стандартный TypeScript.

Дальше всё вытекает само. Домен не может «просто позвать движок» - значит, всё нужное ему приходит аргументом, а всё, что он хочет сделать с миром, он возвращает как данные.

Правила прогресса - чистые функции вида «событие → новое состояние + эффекты». Функция onEntityEnter не удаляет меш и не трогает коллайдер. Она возвращает новый прогресс и список эффектов, которые исполнит кто-то другой.

Порты

Между доменом и внешним миром - интерфейсы. Их немного, и весь контракт игры с физикой влезает в один экран:

export interface PhysicsPort {
  loadLevel(level: LevelDef): void;
  setMoveIntent(x: number, z: number): void;
  requestJump(): void;
  step(dt: number): void;
  getCubeState(): CubeState;
  consumeEvents(): PhysicsEvent[];
  setCubeMass(mass: number): void;
  removeEntity(index: number): void;
  spawnShedDrop(id: number, position: Vec3): void;
  removeShedDrop(id: number): void;
  applyKnockback(): void;
  respawnCube(): void;
}

export interface RenderPort {
  syncLevel(level: LevelDef): void;
  syncCube(cube: CubeState): void;
  removeEntity(index: number): void;
  syncShedDrops(drops: ReadonlyArray<ShedDropState>): void;
  render(): void;
}

Обратите внимание на то, чего в PhysicsPort нет. Нет RigidBody, нет Collider, нет ни одного типа из Rapier. Есть LevelDef, CubeState, Vec3 - мои собственные структуры. Реализация RapierPhysics знает про Rapier; всё, что выше неё, - нет, и это принципиально.

С рендером то же самое: syncCube(cube: CubeState) означает «вот состояние куба, показывай как хочешь». Адаптер на Three.js внутри творит что угодно, включая squash & stretch и пересчёт материала. Наружу это не протекает.

Теперь честно про цену

Порт разрастается. Двенадцать методов в PhysicsPort - это уже не тот изящный интерфейс из книжки. Каждая новая механика, которой что-то нужно от физики, добавляет метод. Красиво не будет.

Один раз пришлось продавить домен. Рендеру для squash & stretch нужна скорость куба. Скорость живёт в физике, а рендер её оттуда достать не может - он видит только CubeState. Пришлось добавить в доменную модель поле velocity, которое самому домену не нужно ни для одного правила.

Это честный компромисс, а не победа архитектуры. Данные поехали через центр ровно потому, что маршрута в обход не нашлось. Такие места лучше называть своими именами, чем задним числом объяснять, что так и задумывалось.

Тюнинг живёт в одном месте. Все константы игры - в src/domain/tuning.ts, и магические числа в адаптерах запрещены. Дисциплина полезная, но к ней надо привыкнуть, а привыкать не всегда хочется.

Что это дало: тесты без браузера

Правила массы, движения, прогресса и watchdog - чистые функции без зависимостей. Гоняются в Vitest пачками: без headless-браузера, без WASM-модуля физики, без канваса. Быстро, и, что важнее, это тесты на правила игры, а не на пиксели.

Физика тестируется отдельно, а Game - с фейковой реализацией портов. Чтобы проверить, что подобранная капля увеличивает массу, Rapier не нужен: достаточно фейка, который вернёт нужное событие.

И главное - детерминированные e2e

Вот здесь всё окупилось разом.

Сначала про цикл. Игра крутится на фиксированном шаге с аккумулятором:

export class FixedTimestep {
  private accumulator = 0;

  constructor(
    private readonly dt: number,
    private readonly maxStepsPerFrame = 5,
  ) {}

  advance(elapsedSec: number, stepFn: () => void): void {
    this.accumulator += elapsedSec;
    let steps = 0;
    while (this.accumulator >= this.dt && steps < this.maxStepsPerFrame) {
      stepFn();
      this.accumulator -= this.dt;
      steps++;
    }
    if (steps === this.maxStepsPerFrame) this.accumulator = 0;
  }
}

maxStepsPerFrame тут не для красоты - это защита от спирали смерти. Кадр отрисовался медленно, накопилось игровое время, движок пытается догнать, следующий кадр от этого ещё медленнее, и так до полной остановки вкладки. Упёрлись в лимит - сбрасываем накопитель и осознанно теряем игровое время. Лучше потерять полсекунды симуляции, чем весь браузер.

Теперь проблема тестов. Playwright-тест «дойди до капли, проверь, что масса выросла» в наивном виде выглядит как «нажми стрелку, подожди секунду, проверь». На моей машине хватает. На софтверном рендере в CI - нет, и тест начинает мигать. Ждать по настенным часам нельзя в принципе, потому что симуляция едет со скоростью отрисовки.

Но раз симуляция отделена от рендера, её можно прокрутить синхронно:

if (import.meta.env.DEV) {
  (window as unknown as Record<string, unknown>).__cubanoid = {
    getState: () => game.getCubeState(),
    getProgress: () => game.getProgress(),
    advance: (seconds: number) => {
      const step = 1 / 60;
      for (let t = 0; t < seconds; t += step) game.tick(step);
    },
  };
}

И тест перестаёт зависеть от железа:

for (let i = 0; i < 40 && c.getProgress().mass !== 5; i++) c.advance(0.1);

Это не «подожди четыре секунды». Это «прокрути ровно четыре секунды игрового времени шагами по 1/60». Результат одинаковый на моей машине, на софтверном рендере и на чужом ноутбуке.

Хук объявлен под import.meta.env.DEV и в продакшен-сборку не попадает.

Бонус: рендер тоже в курсе, что его тестируют

Полный пайплайн с SSAO и bloom на софтверном GL умирает мучительно, поэтому режим качества выбирается сам:

if (requested === 'high' || requested === 'low') {
  quality = requested;
} else if (
  (typeof navigator !== 'undefined' && navigator.webdriver === true) ||
  MOBILE_UA_PATTERN.test(navigator.userAgent)
) {
  quality = 'low';
} else {
  quality = 'high';
}

navigator.webdriver === true - это и есть «нас запустил Playwright». Функциональные e2e идут на low и не тормозят; скриншот для сверки с визуальным референсом снимается отдельно с ?quality=high.

Важно, что решение рендер-локальное: домен, физика и тюнинг про режим качества не знают ничего. Ровно так, как и задумано.

Стоило ли

Для игры, где уровень проходится за полторы минуты, - вопрос спорный, и я не стану утверждать, что так надо делать всем. Но три вещи повторил бы не думая:

  1. Правила игры как чистые функции. Их можно читать, тестировать и обсуждать, не запуская игру.

  2. Фиксированный шаг с ограничителем. Дёшево, десять строк, снимает целый класс проблем.

  3. Синхронная прокрутка симуляции для тестов. Возможна ровно потому, что симуляция не привязана к отрисовке. Именно она превратила мигающие e2e в стабильные, и одного этого хватило бы, чтобы оправдать всю затею.

Интересно услышать противоположный опыт: если вы писали игру «как принято», с объектами, которые знают всё сразу, - где это выстрелило в ногу и выстрелило ли вообще.

Комментарии (8)


  1. overtest
    28.08.2026 10:05

    Зачем в заголовке писать "Объясняю зачем" ?


    1. spovst
      28.08.2026 10:05

      Потому что это классический высер ллмки, как, впрочем, и сам текст статьи. Достаточно полистать ленту, и можно с завидной (нет) регулярностью обнаружить заголовки, сформированные по подобному паттерну.


      1. Dertefter
        28.08.2026 10:05

        Да нет, ллм тут не причем. Это классический паттерн “цепляющего” заголовка. Задолго до появления ЧатГПТ заголовки новостных статей выглядели подобным образом


        1. spovst
          28.08.2026 10:05

          Все же ставлю на ллм. Посмотрите остальные заголовки и статьи этого автора.


        1. overtest
          28.08.2026 10:05

          И сейчас также выглядят на помойках а-ля mail ru, а за Хабр обидно. Насчет "цепляющего" - спорный момент, для более-менее вменяемых людей это индикатор треша, цепляет он только определенный контингент, не свойственный этому ресурсу.


          1. Dertefter
            28.08.2026 10:05

            Насчет “цепляющего” - спорный момент, для более-менее вменяемых людей это индикатор треша, цепляет он только определенный контингент, не свойственный этому ресурсу.

            Именно поэтому слово “цепляющий” я оградил кавычками =)


  1. MANAB
    28.08.2026 10:05

    Т.е. вы пришли к MVC/MVVM?


    1. Unnemed112
      28.08.2026 10:05

      Буквально с языка снял