«Чисте небо: гра про ППО» — браузерна tower defense, у якій мобільні вогневі групи, прожектори, РЕБ, «Гепарди», ЗРК і «Петріот» прикривають ТЕЦ від «шахедів», «гербер» і крилатих ракет. Грати можна прямо в браузері на chystenebo.top — без реєстрації, без реклами, офлайн після першого запуску. Ця стаття — не про геймдизайн, а про те, як вона влаштована всередині: детермінований симулятор, перевірка реплеїв на сервері, баланс, який доводять боти, і 673 тести, що тримають це вкупі.
Що це за гра
Три карти кампанії, кожна зі своїм характером загрози: Київ — «шахеди» й масовані нальоти на ТЕЦ, Одеса — «Калібри» з моря по портовому терміналу, Харків — балістичний коридор і підстанція 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 з рендера — і перевірка реплеїв перестала б збиратися.
Баланс доводять боти, а не відчуття
Складність — найлегше місце, де геймдизайн перетворюється на «мені здається». Тут вона описана умовами, які перевіряє 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 із шириною у відсотках».
Онлайн: клієнт не пише в таблиці
Бекенд — Supabase, вісім міграцій. Правило одне й без винятків: клієнт ніколи не пише в таблицю напряму. Кожен запис — SQL-функція з перевіркою вхідних даних, RLS увімкнено скрізь, а секрети живуть лише на сервері. Поведінку бази перевіряють 57 pgTAP-перевірок: правила нікнеймів і ліміт перейменувань, взаємність друзів, межі тижня, захист черги, каскадне видалення акаунта.
Збереження в акаунті — окрема історія: злиття, яке лише доповнює (ніколи не затирає), порівняння-і-запис за ревізією і питання гравцеві, коли на пристрої зустрілися чужий акаунт і чужий прогрес. Вхід через пошту прикритий Cloudflare Turnstile.
Телефон — головний екран, а не «теж підтримується»
Канва стежить за розміром сцени через 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, офлайн-застосунок, бекенд із жорсткими правилами доступу й тести, що не дають цьому розвалитися. Якщо у вас є задача, де звичайної сторінки замало, — розкажіть про неї, і я скажу, з чого почати.
Скриншоти — з гри. Позиції ППО на них вигадані й не відповідають жодним реальним.