Марио про джуна: платформер на 40 КБ без файлов графики и бот, который проверяет каждый уровень перед выкладкой

Всем привет! Я фронтендер и по вечерам делаю пет-проект Workaem, агрегатор IT-вакансий. Как-то захотелось сделать рядом с ним что-нибудь несерьезное, и за выходные получилась аркада в духе Марио, только про карьеру в IT. Назвал я ее «Путь джуна». Вместо грибов тут офферы, вместо черепах легаси, а в замках вместо дракона сидят собеседования, от тестового задания до финального.
Потом я еще пару вечеров ее доделывал, и по дороге всплыло много такого, с чем я как фронтендер раньше не сталкивался. Фиксированный шаг физики, процедурные уровни, синтез звука, античит. Про это и будет рассказ, а заодно и про баги, которые глазами никак было не поймать.
Claude при этом я использовал, как и в основном проекте. Код он пишет быстро, а вот почему уровень вдруг не проходится или почему таблица рекордов врет, разбирался уже я сам, в основном по скриншотам и логам.
Что за игра
Четыре мира по три уровня, как в классике: Джун, Мидл, Сеньор, Лид. В каждом мире сначала обычный уровень, потом подземелье или небо на лифтах, а в конце замок с мини-боссом. За замок дают грейд: сдал тестовое и стал джуном, и так дальше, до финального собеса и оффера.
Каждый мир приносит что-то свое. В первом враги, ящики и ямы. Во втором появляются трубы, лифты и окно созвона, которое нельзя растоптать. В третьем ротации, болото легаси и техдолг. В четвертом за тобой ползет стена дедлайна, а еще появляются рекрутеры.
Герой растет в три ступени. Джун маленький, одно касание и минус жизнь. С первого оффера он вырастает вдвое и держит удар. А сеньор получает наушники и начинает кидаться тестами. Из ящиков выпадают оффер, тесты, кофе для ускорения и отпуск, который дает неуязвимость.

Почему без React и без картинок
Игру можно открыть в браузере, и из Telegram как Mini App, прям в чате. Ждать загрузку в чате никто не станет, поэтому я с самого начала решил, что своего грузить почти нечего. Сборка на Vite и TypeScript, без фреймворка. Canvas-игре React ни к чему, и npm-зависимостей в рантайме у проекта нет вообще.
Файлов графики тоже нет. Все спрайты и фоны рисуются кодом. Сначала это были прямоугольники, потом я перевел их на кривые и градиенты, и игра перестала выглядеть как набор кубиков. Сейчас JS весит 94 КБ, а вся игра вместе с HTML и CSS около 40 КБ в gzip. Снаружи подтягиваются только SDK Telegram и пиксельный шрифт с Google Fonts.

Оформлений шесть, и я хотел, чтобы они отличались не только цветом. В подземелье над головой потолок и тесно. В ночной смене звезды, луна и втрое больше горящих окон. Горящий прод залит красным аварийным светом, который пульсирует вместе с мигалкой. А по серым стенам замка сразу понятно, что впереди собес.
Звук тоже кодом
Мегабайт сэмплов утроил бы вес сборки, так что все звуки и три фоновые мелодии (поверхность, подземелье с замком и отпуск) синтезируются через Web Audio. Каждый звук это набор нот, у ноты есть частота, момент старта, длительность и иногда съезд частоты:
const PATTERNS: Record<Sound, Note[]> = {
jump: [{ freq: 320, at: 0, dur: 0.14, slideTo: 620 }],
stomp: [{ freq: 180, at: 0, dur: 0.1, slideTo: 60, type: "sawtooth" }],
// Две ноты вверх - та самая интонация подобранной монетки.
coin: [
{ freq: 988, at: 0, dur: 0.07 },
{ freq: 1319, at: 0.06, dur: 0.13 },
],
// ...и еще десяток звуковЕсли не сделать огибающую, каждый звук щелкает в конце. Поэтому у ноты мгновенная атака и экспоненциальный спад почти до нуля:
env.gain.setValueAtTime(0.0001, start);
env.gain.exponentialRampToValueAtTime(peak, start + 0.005);
env.gain.exponentialRampToValueAtTime(0.0001, start + note.dur);Еще браузер не дает играть звук до первого касания, так что AudioContext создается лениво, на первое нажатие кнопки. А если создать его не получилось, игра должна молча работать дальше, а не падать. В отпуске, кстати, своя музыка. В классике половина кайфа от неуязвимости как раз в том, что музыка меняется и ты слышишь, что правила поменялись.
На 144 Гц игра шла в 2,4 раза быстрее
В первой версии шаг физики делался на каждый кадр requestAnimationFrame. У меня все выглядело нормально, пока я не сравнил время прохождения на разных машинах. На 60 Гц уровень проходился за одно время, на 120 Гц вдвое быстрее, на 144 Гц почти в два с половиной раза. Таблица рекордов в таком виде ничего не значила.

Решение известное, физика идет своим ровным шагом, отрисовка своим. Темп задается одним числом TICKS_PER_SECOND = 75, это примерно посередине между тем, как игра шла на 60 и на 120 Гц.
function frame(now: number): void {
let elapsed = now - prevTime;
prevTime = now;
// Вкладку свернули или телефон уснул: без обрезки накопятся сотни шагов,
// и персонаж рванет через полкарты за один кадр.
if (elapsed > 250) elapsed = STEP_MS;
accumulator += elapsed;
let steps = 0;
while (accumulator >= STEP_MS && steps < MAX_CATCHUP) {
accumulator -= STEP_MS;
steps += 1;
step();
}
// Машина не тянет: лучше идти чуть медленнее, чем копить долг.
if (steps === MAX_CATCHUP) accumulator = 0;
renderer.draw(world);
requestAnimationFrame(frame);
}Потолок в пять шагов за кадр оказался почти так же важен, как сам фиксированный шаг. Без него слабый пк/телефон начинает копить долг, каждый кадр считает все больше шагов и уходит в спираль, из которой уже не выбирается.
Уровни собираются из кусков, но у всех одинаковые
Рисовать двенадцать длинных карт руками мне не хотелось, поэтому уровни собираются из кусков. Кусок это десяток строк данных: пол, ямы, враги, ящики, трубы. Новый кусок сразу попадает во все подходящие уровни. Как выяснилось, в такой игре больше всего времени съедает как раз контент.
При этом карта должна быть одинаковой у всех, иначе соревноваться в таблице бессмысленно. Поэтому генератор детерминированный, и у каждого уровня свой зашитый сид:
function rng(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}Это обычный mulberry32, для игры его хватает с запасом. Раз «Стартап» у всех одинаковый, статистику я считаю по уровням. Место на конкретном уровне сравнивать можно, а общий счет за забег нет, он зависит от того, сколько человек успел набегать до смерти.
Сложность куска я руками не проставляю, она считается из того, что в нем лежит. Ямы весят по ширине, враги по виду. Созвон тяжелее легаси, потому что его не растопчешь. Ящики и скиллы не считаются вообще, это награда. Кусков больше сорока, и любая ручная оценка разъехалась бы при первой же правке.
Композитор идет по уровню и на каждом шаге решает, какой сложности кусок нужен в этом месте. В начале полегче, ближе к двери тяжелее. Сверху на это легло несколько правил, и каждое появилось после того, как без него было плохо. Каждый четвертый кусок это передышка, потому что ровный подъем выматывает. Каждый третий берется из того, что принес текущий мир. Без этого новинка тонула в общей куче: во втором мире кусков с трубами четыре из тридцати, и сид вполне мог собрать уровень вообще без труб. Берется не самый подходящий кусок, а случайный из ближайшей четверти, иначе уровень превращается в предсказуемую лесенку. И два одинаковых куска подряд не ставятся, это читается как копипаста.

Две вещи я все же рисовал руками. Первый уровень учит управлению без единой текстовой подсказки. Там восемь коротких кусков, и в каждом по одной новой вещи: идти, прыгнуть, растоптать, разбить ящик, перепрыгнуть яму. Вторая вещь это концовка каждого уровня. Середина карты собирается случайно, и это нормальный размен, длина достается даром. Но «того самого места» случайная нарезка не дает. Марио ведь помнят не уровнями целиком, а конкретной трубой и конкретной лестницей. Поэтому последний кусок перед дверью у каждого уровня свой: на «Галере» над головой ряд весел, в «Стартапе» три легаси на равном расстоянии, и цепочка собирается сама собой.

У сложности появилась форма
Сначала двенадцать уровней шли подряд, и сложность в них скакала. Заметил я это не сразу. На шестом уровне стояло 23 наземных врага, больше, чем на любом из следующих. Я этого не видел, потому что каждый уровень собирался сам по себе и его никто не мерил.
Тогда я завел одно число, опасность готовой карты. Веса те же, что у кусков, только считается по собранному уровню, вместе с концовкой, боссом и стеной дедлайна. И разложил уровни по мирам так, чтобы у сложности была форма. Внутри мира она растет к замку, а на входе в следующий мир проседает. Новая механика встречает тебя на спокойной карте, и кажется, что ты стал сильнее, хотя на деле тебе просто дали передохнуть.

Эту форму держит проверка check:curve в сборке. Если после правки кусков третий уровень мира вдруг станет легче второго или первый уровень мира окажется тяжелее замка предыдущего, сборка упадет, и на сервер это не уедет.
Глазами такие баги не ловятся
Карты процедурные, так что проверять их глазами бесполезно. Сломанное место может выпасть на одном сиде из десяти. Я перестал надеяться на ручные прохождения, и почти каждый баг ниже превратился в проверку внутри npm run build.

Например, герой парил над землей. Большой спрайт рисовался высотой 15 пикселей, а хитбокс у него был 22. Ноги хитбокса стояли на земле ровно, а нарисованный человечек заканчивался на семь пикселей выше. Со стороны казалось, что он просто как-то странно прыгает, и искать ошибку ровно в семь пикселей я не догадался. Чинить пришлось спрайт, хитбокс трогать нельзя, на этих 22 пикселях держится вся геометрия уровней. Проверка check:sprites подсовывает рисовалке подложный объект, который вместо рисования запоминает границы прямоугольников, и сравнивает получившийся рост с хитбоксом.
Потом начал дрожать лифт. У вертикального лифта размах отрицательный, и границы хода получались перепутанными, from 67, to 45. Разворот срабатывал в обе стороны каждый кадр, платформа тряслась, и игрока на ней трясло вместе с ней. Теперь сборка проверяет, что каждый лифт реально проходит свой размах.
А еще уровень иногда не засчитывался. Финиш проверялся как коробка высотой 24 пикселя от земли, а игрок, прилетевший в прыжке, пролетал выше нее. Уровень не заканчивался, за дверью оставалось пять пикселей до стены, и там игрок застревал навсегда. Рядом нашелся еще один: удар по ящику снизу не срабатывал, потому что проверка шла уже после того, как столкновение вытолкнуло игрока из ящика.
Эти два последних бага нашел не я, их нашел бот.
Бот, который проверяет уровни перед выкладкой
Этого бота игроки не видят, в игре человек играет сам. Бот живет в проверках: npm run playtest входит в сборку и проходит все двенадцать уровней той же физикой, что и игра, включая бои с боссами. Никакого упрощенного симулятора, тот же world.ts и те же константы, только решения вместо клавиатуры принимает код. Если какой-то уровень пройти не получилось, сборка падает, и на сервер ничего не уезжает.
Одной стратегии прыжка боту мало. Живой игрок дозирует прыжок по ситуации, а одна эвристика так не умеет: подкрутишь ее под одно место, сломается другое. Поэтому уровень считается проходимым, если его прошла хоть одна стратегия. Важно было знать, можно ли тут пройти в принципе, а насколько хорош мой бот, дело десятое.
Стратегий семь. Шесть держат одну длину прыжка на весь уровень, они так и называются: короткий, средний, длинный, ранний, поздний и осторожный. Седьмая, «по ступенькам», подбирает прыжок под следующую опору. Она нужна там, где яму переходят по балкам: с полным прыжком с разбега игрок пролетает мимо ступеньки и бьется головой в ее торец. Если не справилась ни одна, бот перебирает 60 вариантов, где длина каждого прыжка случайная, но от зерна. Так результат воспроизводится, и упавшую проверку можно спокойно повторить.
Сначала бот прыгал всегда одинаково и то и дело объявлял непроходимыми уровни, которые человек проходит спокойно. Живой игрок перед широкой ямой прыгает изо всех сил, а на мостике из балок делает короткие шажки. С боссом все проще, он сам идет на игрока, так что бот подпускает его, прыгает на голову с разбега и отходит, пока тот неуязвим.
Остальные проверки попроще и считают то, что человек заметит разве что случайно. check:play следит, чтобы каждого врага можно было растоптать, а каждый ящик и скилл достать. Враги умудрялись ходить под низкими ящиками, где их не растопчешь. check:rules поймал бонусную комнату, где верхний ряд скиллов висел выше прыжка. Досягаемость там считается по макушке в верхней точке прыжка, а не на глаз.
Античит, который отверг моего же бота
Очки, посчитанные на клиенте, подделываются за минуту. Поэтому на сервер уходит не только счет, но и состояние забега: кадры, прыжки, растаптывания, смерти, разбитые ящики. По нему отсекаются невозможные результаты, например уровень за восемь секунд.
И минимальное время, и потолок очков считаются из самой карты, из ее длины, числа скиллов, врагов, ящиков и заначек. Руками эти константы я не пишу, их генерирует npm run anticheat из данных игры. Иначе добавишь новый уровень, в воркере останется старое число, и человек, прошедший все до конца, будет отвергнут как накрутчик.
Первый раз эта проверка сработала не на подделке, а на моем собственном боте. Бот играет настоящей физикой, и его результат обязан проходить отсечку. Он не прошел: мой же потолок очков отвергал нормальную игру. С тех пор запас по времени я закладываю щедрый. Если отвергнуть нормального игрока, он просто уйдет и больше не вернется, и это хуже, чем пропустить одного накрутчика.
Игроков из Telegram я проверяю по подписи initData. Это HMAC-SHA-256, ключ для которого сам считается как HMAC-SHA-256 от токена бота с константой WebAppData, плюс сравнение за постоянное время и проверка срока auth_date. Таблица рекордов живет на Cloudflare Workers и D1. Дальше хочу перейти на проверку реплеем ввода вместо потолка очков, но руки до этого пока не дошли.
Что я подсмотрел у Марио
Пока делал игру, понял, что случайных деталей в классической игре почти нет. Что-то я подсмотрел сразу, а до чего-то дошел уже потом.
Например, цепочка растаптываний. Прыгнул на одного врага, потом на второго, на третьего, ни разу не коснувшись земли, и каждый следующий стоит дороже: 200, 400, 800, 1000, 2000, 4000. После шестого дают жизнь. И тут появляется смысл рисковать и скакать по головам, лишняя жизнь того стоит.
Очки видно там, где ты их заработал. Сначала счет молча менялся в шапке, куда во время прыжка никто не смотрит, и связи «сделал и получил» не было. Теперь цифра всплывает прямо над растоптанным врагом.
Финиш сделан флагштоком, как в оригинале. Чем выше за него зацепился, тем больше бонус, за верхушку дают втрое больше, чем у основания. Ради этой разницы перед финишем стоит лесенка, чтобы было откуда прыгнуть повыше.

Время теперь на виду. Норма на уровень была и раньше, но жила только в формуле бонуса. Игрок про нее не знал и, понятно, на нее не играл. Теперь в шапке идет обратный отсчет, на последних пятнадцати секундах он краснеет, а музыка разгоняется. Норма у каждого уровня своя, из длины карты и скорости. С одной нормой на всех короткий первый мир давал звезду даром, а в длинном последнем ее было не взять.
Продолжения нет. Потерял все жизни, и путь начинается с первого уровня. Зато каждый пройденный уровень восполняет жизни до трех. Экран поражения называется «Выгорание» и показывает, сколько уровней пройдено и сколько набрано скиллов.

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

Что еще сломалось по дороге
Черный экран на старых iPhone. Вся графика шла через ctx.roundRect, а он появился только в Safari 16.4. На телефоне, который давно не обновляли, первый же кадр падал с TypeError, и человек видел черный экран вместо игры. Теперь, если метода нет, скругления рисуются дугами, а цель сборки я опустил до es2020. Safari на IOS обновляется только вместе с системой, и если в бандле попадется незнакомый ему синтаксис, страница просто останется пустой.
Замер производительности, который врал. После перехода на кривые я замерил стоимость кадра циклом из вызовов draw и получил 0,7 мс, которые вообще не росли с плотностью холста. Оказалось, Canvas2D складывает команды в очередь, и я мерил их сборку, а не само рисование. Настоящую цифру показывает дрожание кадров под requestAnimationFrame. По ней я и опустил потолок плотности холста до 2. На телефоне видеокарта в разы слабее, а разницы между 2 и 3 на ретине все равно не видно.
Деплой, который тоже врал. Скрипт выкладки в конце проверял код ответа, и код был 200 даже тогда, когда заливка не прошла: сервер бодро отдавал старую версию. Однажды оборвался rsync, set -e молча закрыл скрипт, и игра осталась старой, с багом, который я считал исправленным. Теперь скрипт сверяет имя собранного бандла с тем, что реально отдает сайт.

Итог
Игра получилась примерно на 7,5 тысяч строк TypeScript, и еще около двух тысяч ушло на проверки. Самое полезное, что я отсюда вынес, потом переехало и в основной проект. Если чего-то много или оно генерируется, я больше не проверяю это глазами, а пишу проверку, которая упадет в сборке.
Буду рад технической обратной связи. Может чего не хватает и можно было бы добавить?
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.