Проект epich.ru устроен прямолинейно: несколько Git-сабдиректорий, каждая со своим Dockerfile. Статические домены собираются за одно FROM nginx:alpine / COPY public/ /usr/share/nginx/html/ и живут отдельно. Подключить React сюда можно, но тогда появится сборочный шаг, node_modules и Dockerfile из двух stage. Для игры на 1000 строк это неоправданная цена.

Задача звучала конкретно: текстовый квест про бомжа с атмосферой, ломающим четвёртую стену, grotesque SVG-сценами, Web Audio синтезом без mp3-файлов, инвентарём и несколькими концовками. Всё в браузере, без сервера.

Архитектура: 5 файлов и папка SVG

Игра разделена на четыре JS-файла с иерархией зависимостей: каждый следующий опирается на предыдущий. Порядок тегов script в index.html критичен.

flowchart LR subgraph html["index.html"] direction TB E["engine.js
Engine
state, audio, DOM"] I["items.js
ITEMS
onUse, helpers"] SC["scenes.js
SCENES
text, choices"] G["game.js
Engine.init()"] end E -->|читает| SC E -->|читает| I SC -->|вызывает| E I -->|вызывает| E G -->|запускает| E E -.->|img src| SVG["svg/*.svg
nginx static"] E -->|обновляет| DOM["DOM
#scene-svg
#scene-text
#choices
"] E -->|синтез| Audio["Web Audio API
5 настроений"]
Зависимости между модулями. SVG-иллюстрации загружаются как обычные img через nginx.

Разделение не ради ради принципа, а ради редактируемости. Сценарист открывает scenes.js и видит только текст и логику переходов. Художник редактирует SVG-файлы в папке. Механики инвентаря сосредоточены в items.js, а движок при этом остаётся нетронутым.

Движок: IIFE, состояние и функция go()

Весь движок оформлен как IIFE-модуль, который возвращает публичный API. Никакого class, никакого синглтона через import. В основе лежит обычное замыкание:

const Engine = (() => {
  const S = {
    sceneId:   null,
    inventory: new Set(),  // Set<string> -- id предметов
    flags:     new Map(),  // Map<string, any>
    visits:    new Map(),  // Map<sceneId, number>
    step:      0,
  };

  function go(sceneId) {
    const scene = SCENES[sceneId];
    if (!scene) return;
    S.sceneId = sceneId;
    // ВАЖНО: инкремент выполняется ДО вызова text() и choices()
    S.visits.set(sceneId, (S.visits.get(sceneId) || 0) + 1);
    S.step++;
    // ... рендер SVG, заголовка, текста, кнопок
    if (scene.onEnter) scene.onEnter();
  }

  return { go, init, addItem, removeItem, hasItem, visitCount, ... };
})();

Состояние игры полностью инкапсулировано. Сцены и предметы обращаются к нему через публичный API: Engine.hasItem('lighter'), Engine.visitCount('yard_morning'), Engine.setFlag('cat_spoke', true). Это снижает связность: сцена не импортирует состояние напрямую, она общается через контракт.

Кнопки выбора рендерятся функцией setButtons(choices). У каждого choice либо строковое action: 'scene_id', либо функция. Движок приводит оба варианта к единому формату исполнения:

function setButtons(choices) {
  const btns = $id('choices');
  const padded = [...choices.slice(0, 4)];
  while (padded.length < 4) padded.push(null);
  padded.forEach((ch, i) => {
    const b = btns.children[i];
    if (!ch) { b.textContent = '· · ·'; b.disabled = true; return; }
    b.textContent = ch.label;
    b.disabled = false;
    b.onclick = () => {
      playClick();
      if (typeof ch.action === 'string') go(ch.action);
      else ch.action();
    };
  });
}

Всегда четыре кнопки в сетке 2x2. Пустой слот показывает "· · ·" и заблокирован. Это создаёт ощущение завершённого UI без необходимости динамически менять раскладку.

Web Audio без файлов: синтез пяти настроений

Для атмосферы нужен звук. Хранить mp3-файлы внутри Docker-образа можно, но это увеличивает размер и усложняет деплой. Web Audio API позволяет синтезировать звук прямо в браузере без единого аудиофайла.

В игре пять режимов атмосферы: calm, creepy, horror, void, transcend. Каждый режим создаётся набором осцилляторов и шумовых буферов:

// Нарастающая жуть: расстроенные осцилляторы + низкочастотный гул
creepy() {
  const ns = [];
  for (const [freq, vol] of [[110, .03], [110.9, .025], [109.1, .025]]) {
    const o = A.createOscillator(); o.type = 'sawtooth'; o.frequency.value = freq;
    // LFO медленно раскачивает частоту -- создаёт эффект нестабильности
    const lfo = A.createOscillator(); lfo.frequency.value = 0.08;
    const lg = A.createGain(); lg.gain.value = 1.8;
    lfo.connect(lg); lg.connect(o.frequency);
    const g = A.createGain(); g.gain.value = vol;
    o.connect(g); g.connect(ambientGain);
    o.start(); lfo.start(); ns.push(o, lfo);
  }
  // Шум через lowpass-фильтр = низкочастотный фоновый гул
  const n = makeNoise(3);
  const lp = A.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 65;
  const ng = A.createGain(); ng.gain.value = 0.09;
  n.connect(lp); lp.connect(ng); ng.connect(ambientGain); n.start(); ns.push(n);
  return ns;
}

Три слегка расстроенных осциллятора на 110, 110.9 и 109.1 Гц создают биения, характерные для жуткой атмосферы. Каждый медленно качается LFO с периодом 12 секунд. Слушатель воспринимает это как нечто неправильное, но не может сразу объяснить что именно.

При смене сцены движок плавно затухает старые ноды и запускает новые через ambientGain.gain.linearRampToValueAtTime. Смена настроения занимает 2 секунды.

AudioContext нельзя запускать без жеста пользователя. Поэтому аудио инициализируется не при загрузке страницы, а на первый click/keydown. Это стандартное ограничение браузеров, и оно правильно: пользователь должен сознательно начать игру.

SVG как отдельные файлы: простой подход

Каждая сцена имеет иллюстрацию. Первый рабочий вариант хранил SVG-разметку прямо в scenes.js как template literal. Это плохо: большие строки в логике, подсветка синтаксиса не работает, художнику неудобно редактировать.

Отдельные файлы в public/svg/ решают проблему. Nginx раздаёт их как статику. В сцене хранится тег img:

function sceneSVG(name) {
  return `<img src="/svg/${name}.svg" width="100%" style="display:block;min-height:220px">`;
}

const SCENES = {
  yard_morning: {
    svg: sceneSVG('yard_morning'),
    // ...
  }
};

Движок вставляет эту строку через innerHTML контейнера #scene-svg. Браузер загружает SVG как обычное изображение. CSS glitch-эффект при переходах применяется к контейнеру и захватывает img вместе с ним.

Стиль иллюстраций: grotesque cartoon. Намеренно кривые линии, диспропорции, гротескные черты лица. Всё написано руками через path, ellipse, rect с stroke-linecap="round". Никаких трассировок, никаких внешних шрифтов кроме системного monospace.

Четвёртая стена: время суток и кошка с голосом

Игра знает, что за ней наблюдают. Это реализуется несколькими простыми трюками.

Первый: приветствие зависит от времени суток реального пользователя. Вступительный текст сцены intro обращается к Engine.getTimeOfDay(), которая читает new Date().getHours(). Игрок в три часа ночи видит "Ночью играют в такое." Игрок утром видит другое.

Второй: счётчик посещений. Каждый вызов Engine.go(sceneId) инкрементирует S.visits. Сцена коробки на третьем визите замечает пятно на потолке "в форме руки с указательным пальцем, направленным вниз". На четвёртом пятно исчезает и сменяется надписью.

Третий: предупреждение, которое подтверждает разные источники. Кошка на заборе говорит "Не ходи к Семёну". Надпись на стене у мусорных баков: "Семён знает. Не иди". При повторном визите к бакам надпись уже другая: "Уже поздно". При третьем посещении стена чистая, как будто никакой надписи никогда не было.

Кнопка "Кто это делал?" во вступлении показывает мета-текст: "Эта игра знает, что ты здесь. Она не будет притворяться, что не знает." Сюрпризом это не назовёшь: контракт с игроком заключается в самом начале.

Два бага, которые нашлись сразу

visitCount равен 1 на первом визите

Первый прогон показал: сцена box_morning на первом посещении показывает текст второго визита. Причина оказалась в порядке операций внутри go(). Инкремент счётчика выполняется до вызова scene.text() и scene.choices().

Движок ведёт себя по контракту: к моменту рендера сцена уже "посещена". Все проверки в scenes.js должны учитывать это: первый визит = visitCount === 1, а не 0.

Исправление прямолинейное: заменить все n === 0 на n === 1, n >= 1 на n >= 2 и так далее по всем сценам. И добавить комментарий в код, чтобы следующий редактор не воспроизвёл ту же ошибку.

Буквальные переносы строк в JS-строках

При добавлении обработчиков для items.js через инструмент замены файлов, в строковых литералах появились буквальные (unescaped) переносы строк. JavaScript их не допускает внутри одинарных или двойных кавычек. Браузер выбросил "Invalid or unexpected token".

Решение: для многострочного текста использовать template literals с backtick-кавычками. Это явная защита от такой ошибки, поскольку `...` разрешает реальные переносы строк.

Структура сцены

Каждая сцена в SCENES оформлена как объект с несколькими полями. Ничего лишнего:

back_alley: {
  mood:  'creepy',
  title: 'Переулок',
  svg:   sceneSVG('back_alley'),

  text() {
    const n = Engine.visitCount('back_alley'); // 1 = первый визит
    if (n === 1) return `Переулок. Узкий, тёмный...`;
    if (n === 2) return `В конце появилась дверь...`;
    // Нарастание ужаса при каждом возвращении
    return `Дверь открыта. Пахнет корицей.`;
  },

  choices() {
    const n = Engine.visitCount('back_alley');
    return choices(
      n >= 4 ? { label: 'Войти в комнату', action() { /* концовка */ } }
             : { label: 'Идти вглубь', action() { /* нагнетание */ } },
      useItemChoice('lighter', 'Посветить зажигалкой', 'back_alley'),
      useItemChoice('phone',   'Включить телефон',     'back_alley'),
      { label: 'Назад во двор', action: 'yard_morning' },
    );
  },

  onEnter() {
    const n = Engine.visitCount('back_alley');
    if (n >= 4) Engine.setMood('horror');
    else if (n >= 2) Engine.setMood('creepy');
  },
},

Логика прогрессии полностью в одном объекте. text() описывает что игрок видит, choices() что может сделать, onEnter() меняет атмосферу при входе. Хелперы useItemChoice и choices() фильтруют null-значения, и кнопки с предметами появляются автоматически, когда предмет есть в инвентаре.

Результат и цифры

1363 строк JS и HTML (движок + предметы + сцены)
581 строк SVG в 5 иллюстрациях
5 сцен в продакшне, 30+ запланировано
0 зависимостей, аудиофайлов и шагов сборки

Игра живёт на bomj.epich.ru. Dockerfile занимает две строки: FROM nginx:alpine и COPY public/. Образ пересобирается только если изменились файлы домена: деплой-скрипт проверяет SHA256-хеш директории.

Архитектура масштабируется линейно: новая сцена оформляется как новый объект в SCENES, новая SVG-иллюстрация ложится в папку, при необходимости новый предмет в ITEMS. Никаких регистраций, никаких импортов, никаких конфигурационных файлов.

Осталось написать ещё 25 сцен и пять концовок.