Нічна карта Києва у грі «Чисте небо»: маршрути загроз, прожектори й позиції ППО навколо ТЕЦ
Ніч над Києвом, хвиля 11 із 15. Пунктир — маршрути загроз, конуси — промені прожекторів.

«Чисте небо: гра про ППО» — браузерна tower defense, у якій мобільні вогневі групи, прожектори, РЕБ, «Гепарди», ЗРК і «Петріот» прикривають ТЕЦ від «шахедів», «гербер» і крилатих ракет. Грати можна прямо в браузері на chystenebo.top — без реєстрації, без реклами, офлайн після першого запуску. Ця стаття — не про геймдизайн, а про те, як вона влаштована всередині: детермінований симулятор, перевірка реплеїв на сервері, баланс, який доводять боти, і 673 тести, що тримають це вкупі.

~30kрядків TypeScript
673тести
0байтів аудіофайлів
3карти

Що це за гра

Три карти кампанії, кожна зі своїм характером загрози: Київ — «шахеди» й масовані нальоти на ТЕЦ, Одеса — «Калібри» з моря по портовому терміналу, Харків — балістичний коридор і підстанція 330 кВ, де вперше з'являється «Петріот». Після кампанії відкриваються чотири операції й нескінченна оборона з тижневим сідом, однаковим для всіх.

  • 6 засобів оборони по 3 рівні, 6 видів загроз, свій контрпрофіль у кожного;
  • цикл дня і ночі: вночі неосвітлену ціль видно гірше, тож прожектор перестає бути декором;
  • командири як пасивні модифікатори, здібності «Авіаційне перехоплення» і «РЕБ-купол»;
  • 27 медалей, зірки за якість проходження, тижневий рейтинг і друзі — усе це опційне;
  • PWA: встановлюється як застосунок і грає без мережі.

Детермінований сим — головне технічне рішення

Уся ігрова логіка живе в src/sim/ і не знає ні про Pixi, ні про DOM, ні про випадковість із Math.random(). Крок фіксований — 1/60. Кожна успішна команда гравця записується в журнал разом із номером кроку, на якому сталася.

Звідси випливає все інше. Повтор бою — це просто повторний прогін журналу тим самим кодом. Клієнт не надсилає на сервер очки: він надсилає журнал. Serverless-функція на Vercel переграє його тим самим симулятором і сама вирішує, скільки там хвиль і зірок. Підробити результат можна лише одним способом — справді виграти.

Пастка, на якій це все мало зламатися. Math.sin, cos, atan2, pow, exp, log і решта трансцендентних функцій не гарантують побітово однакового результату в V8, JavaScriptCore і SpiderMonkey. Один біт різниці в куті — і через тисячу кроків бій розходиться. Тому в логіці симулятора дозволені лише + − × ÷ і sqrt, а кути рахуються виключно у шарі відображення.

Правило тримають два тести. Перший сканує вихідники src/sim/ і падає на будь-якій забороненій математиці. Другий — цікавіший: він підміняє кожну «ненадійну» функцію на таку, що повертає результат, помножений на 1 + 2⁻⁴⁰ — рівно та різниця в останньому біті, яку міг би дати інший рушій, — і перевіряє, що контрольна сума повтору не зрушила. Плюс золоті прогони: зафіксовані фінальні стани, які мусять відтворюватися байт у байт, і SIM_VERSION, який доводиться піднімати свідомо.

Архітектуру тримає тест, а не домовленість

«Логіка окремо від відображення» — це обіцянка, яку порушують на третьому тижні. Тому вона записана як тест, а не як рядок у README.

  • кожен файл у src/sim/ може імпортувати лише сусідній файл симулятора і не має права згадувати window, document чи localStorage;
  • src/progress/ (медалі, підсумки забігів) імпортує лише симулятор і себе;
  • src/server/ і функція перевірки — лише симулятор і валідатор схем.

Причина практична: той самий код симулятора виконується у браузері й на Node у serverless-функції. Один випадковий import з рендера — і перевірка реплеїв перестала б збиратися.

Баланс доводять боти, а не відчуття

Денна карта Києва: позиції ППО вздовж маршрутів, панель хвилі «Масований наліт» і кнопка «Почати хвилю»
Та сама карта вдень, перед масованим нальотом: 26 «шахедів», 6 «гербер» і 3 крилаті ракети.

Складність — найлегше місце, де геймдизайн перетворюється на «мені здається». Тут вона описана умовами, які перевіряє CI:

  • бот, який нічого не будує, програє рано;
  • бот, який ставить лише кулемети, теж програє;
  • еталонний план кожної карти виграє на всіх сідах — але жодного разу бездоганно;
  • кожна місія у порядку кампанії складніша за попередню.

Останній пункт міряє популяція випадкових, але осмислених порядків забудови плюс експертний пошук. Коли я правив хвилі Одеси й Харкова, саме цей тест показував, що «фінал став важчим» — а не моє враження після третього проходження поспіль.

Три карти, одна геометрія

Карта Одеси у грі: портовий термінал, «Калібри» заходять із моря, влучання біля причалу
Одеса: «Калібри» заходять із моря низько й швидко — тут виграє не дальність, а щільність.

Карти спершу народжуються як код, а не як картинка. Python-скрипти малюють геометрію міста, маску води й автоматично розставляють слоти за правилами гри; результат лягає в layout.json, з якого живе вже ігрова карта. Правила перевіряє тест: слот не ближче 55 одиниць до маршруту, не ближче 90 до сусіднього слота, не ближче 130 до цілі й не на воді.

Маршрути при цьому зафіксовані назавжди. Змінювати можна слоти, маршрути — ні: інакше розсипалися б збережені реплеї.

Нічний Харків у грі: балістичний коридор до підстанції, перехоплення над житловими кварталами
Харків, «Реактивна ніч»: балістичний коридор іде прямо згори, і встигнути можна лише «Петріотом».

Арт: AI-рендери, але з жорстким конвеєром

Спрайти згенеровані, але кожен проходить ручне підтвердження до того, як потрапляє в гру, і далі — автоматичний конвеєр: виріз фону по маджентовому ключу, обрізання порожнечі, WebP не більший за 256 px, окремо іконки PWA.

Одна деталь, яку легко недооцінити. У техніки є вигляд для рівнів 2 і 3 — більша гармата, додатковий радар, щит. Якщо просто підміняти спрайт, машина на апгрейді «стрибає»: у нового файлу інші поля. Тому конвеєр записує для кожного спрайта координати його реального вмісту в JSON, і гра ставить рівні 2–3 по цій рамці. Рівень 1 лишився байт у байт тим самим файлом, а техніка під час апгрейду не смикається.

Нуль байтів звукових файлів

У грі немає жодного mp3, ogg чи wav. Увесь звук — процедурний WebAudio: постріли, детонації, сирена, гудіння двигуна «шахеда», мажорний акорд «відбою» після вистояної хвилі. Кожен звук проходить через одну з двох шин — «Ефекти» і «Атмосфера», зі своїми повзунками в паузі; тест забороняє підключати джерело напряму в майстер-шину, бо інакше один забутий звук ігнорував би гучність.

Побічний ефект приємний: гра важить стільки, скільки важить її арт, і працює офлайн без завантаження медіа.

Атмосфера, яка сама знижує якість

Сніг над Києвом, морський туман і маяк в Одесі, нічні грози в Харкові, пил у променях прожекторів, тепле марево від збитої ракети, світанок після перемоги — усе це окремий шар, який ніколи не торкається логіки. Чиста функція вирішує, яка погода на цій хвилі й який зараз сутінок; Pixi-шар лише малює.

  • нічого не створюється щокадрово — спрайти беруться з пулів;
  • режим «мало» обмежує малювання 30 кадрами на секунду;
  • системне «зменшити рух» вимикає декоративну анімацію наживо, без перезапуску;
  • на повільному пристрої ефекти знижуються самі, за виміряним часом кадру;
  • ?fps показує лічильник часу кадру.

CSP без 'unsafe-inline' — і чим за це платиш

Політика безпеки в грі строга: style-src 'self', жодного 'unsafe-inline'. Наслідок прямий — у розмітці не може бути жодного атрибута style=, усі стилі ставляться через CSSOM. Смужки прогресу довелося зробити нативними <progress> замість звичного «div із шириною у відсотках».

Граблі, про які варто знати всім, хто робить PWA. Встановлений застосунок віддає закешовану сторінку зі старими заголовками. Тож зміна CSP має їхати в одному релізі зі зміною самої сторінки — інакше у частини гравців нова сторінка зустрінеться зі старою політикою і просто не запуститься.

Онлайн: клієнт не пише в таблиці

Бекенд — Supabase, вісім міграцій. Правило одне й без винятків: клієнт ніколи не пише в таблицю напряму. Кожен запис — SQL-функція з перевіркою вхідних даних, RLS увімкнено скрізь, а секрети живуть лише на сервері. Поведінку бази перевіряють 57 pgTAP-перевірок: правила нікнеймів і ліміт перейменувань, взаємність друзів, межі тижня, захист черги, каскадне видалення акаунта.

Збереження в акаунті — окрема історія: злиття, яке лише доповнює (ніколи не затирає), порівняння-і-запис за ревізією і питання гравцеві, коли на пристрої зустрілися чужий акаунт і чужий прогрес. Вхід через пошту прикритий Cloudflare Turnstile.

Телефон — головний екран, а не «теж підтримується»

Гра «Чисте небо» на телефоні в горизонтальній орієнтації: HUD у бічних смугах, панель хвилі справа
Телефон у горизонталі: HUD і панель хвилі йдуть у бічні смуги, щоб не перекривати карту й виріз камери.

Канва стежить за розміром сцени через ResizeObserver, тож поворот екрана чи зникла панель браузера не ламають розкладку. На коротких широких екранах HUD і панель хвилі їдуть у бічні смуги — повз виріз камери; карта за потреби стискається, але не більш ніж на 15%. Подвійний тап по командиру одразу починає бій, а подвійний тап по карті не зумить (жест «щипок» лишився).

Чого гра навмисно не робить

Брифінг операції «Ставка» у грі: опис завдання і вибір командира з картками
Брифінг операції. Командири вигадані — і це не стилістика, а вимога.

Тема реальна, тож частина правил — не про смак, а про межі. Деякі з них теж записані як тести.

  • Позиції ППО вигадані. Слоти забудови — ігрова абстракція, не мапа реальних розрахунків; це питання ст. 114-2 ККУ, а не естетики.
  • Командири вигадані — без позивних реальних військових, імен загиблих і номерів бригад. Операції — це удари по об'єктах, ніколи по людях; тест сканує їхні тексти.
  • Жодна медаль не нагороджує за кількість убитих, а текст, яким гравець ділиться, містить лише місто й результат.
  • Без лутбоксів, без внутрішньої валюти, без pay-to-win, без «врятувати ТЕЦ за гроші» посеред бою.
  • Без серій, таймерів і push-сповіщень. Натомість після 30 хвилин гри на межі хвилі гра пропонує перерву.
  • Статистика анонімна й агрегована: жодного ідентифікатора пристрою, мовчить під GPC і DNT, вимикається перемикачем.
  • 20% чистого доходу гри йдуть на «Взаємодопомогу» — мій проєкт сусідської допомоги взимку.

Стек одним списком

Шар Чим зроблено
ЗбіркаVite 8, TypeScript 7 (strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes)
ГрафікаPixiJS 8 (WebGL), власні пули спрайтів
Застосунокvite-plugin-pwa + Workbox, повний офлайн
ЗвукWebAudio, процедурний, без файлів
ДаніValibot для всього, що приходить зі сховища й мережі
БекендSupabase (Postgres, RLS, SECURITY DEFINER-функції), Cloudflare Turnstile
Перевірка реплеївVercel Function на тому самому коді симулятора
ТестиVitest (673), pgTAP (57), Playwright для браузерних сценаріїв
Якість кодуBiome 2, заборонені any, console.* і !-асерти

Що далі

Попереду закритий плейтест і публічний старт. Не перевірено поки що головне — як поводиться перевірка реплеїв під реальним навантаженням і чи справді записи з Safari відтворюються на Node байт у байт (за задумом мусять, але «за задумом» — не те саме, що «перевірено»). Обгортка для Google Play — після веб-старту.

Питання

Скільки коштує гра?

Зараз вона безкоштовна і без реклами. Є кнопка «Підтримати гру» — внесок добровільний і нічого в грі не відкриває. 20% чистого доходу гри я спрямовую на свій проєкт «Взаємодопомога».

Чи треба реєструватися, щоб грати?

Ні. Кампанія, операції й нескінченна оборона грають без акаунта — прогрес лежить у браузері. Акаунт потрібен лише для тижневого рейтингу, друзів і для того, щоб прогрес переходив між пристроями.

Чому гра у браузері, а не в Google Play чи App Store?

Правила магазинів окремо обмежують ігри про поточні збройні конфлікти, а комісія забирає 15–30% з кожного платежу. Веб-версія не проходить рев'ю магазину, встановлюється як застосунок і працює офлайн. Обгортку для Google Play я розглядаю, але після веб-старту.

Чи можна накрутити результат у рейтингу?

Клієнт не записує очки — він надсилає журнал своїх команд. Серверна функція переграє цей журнал тим самим кодом симулятора і сама обчислює результат. Підроблений рахунок без валідного журналу просто не підтвердиться.

Чи можете зробити таку гру або складний інтерфейс мені?

Так. Браузерні ігри, інтерактивні конфігуратори, карти, дашборди — усе, де замало звичайного сайту. Опишіть задачу у формі на сторінці послуг, і я скажу, з чого почати і скільки це займе.

Потрібна гра, конфігуратор чи складний інтерфейс?

«Чисте небо» — приклад того, що я можу зробити поза звичайним сайтом: детермінована логіка, яку перевіряє сервер, графіка на WebGL, офлайн-застосунок, бекенд із жорсткими правилами доступу й тести, що не дають цьому розвалитися. Якщо у вас є задача, де звичайної сторінки замало, — розкажіть про неї, і я скажу, з чого почати.

Скриншоти — з гри. Позиції ППО на них вигадані й не відповідають жодним реальним.