Создатель GitHub-репозитория превратил его в полноценную игру
Помните легендарную Million Dollar Homepage? В 2005 году предприимчивый студент продал миллион пикселей по баксу за штуку, заработав целое состояние. Сами по себе эти пиксели не несли никакой ценности, но люди охотно платили ради причастности к завирусившемуся мему и возможности занять заметное для всех пространство.
upd: определенная польза все же присутствовала: покупатели получали бэклинк с топовой на тот момент страницы, что неплохо бустило их сайты в поисковой выдаче Google. Тем не менее, многие раскошеливались просто ради фана и участия в глобальном флешмобе.
Мне захотелось воплотить нечто подобное для разработчиков, заменив привычные деньги на валюту, которую мы и так неустанно коллекционируем — звезды на GitHub.
Так родился Star Throne — интерактивный репозиторий-игра. Достаточно заастерискать проект, и уже через пару минут GitHub Actions вычисляет ваш личный «вес». Если вы оказываетесь лидером в своей весовой категории, ваша аватарка красуется на троне прямо в README. Никаких выделенных серверов, баз данных или стороннего хостинга — вся магия происходит внутри инфраструктуры GitHub.
Дальше я подробно разберу внутреннее устройство проекта, объясню, почему лиги пришлось привязать к возрасту аккаунта, а не к количеству звездочек, и поделюсь граблями, на которые я наступил в первые же сутки релиза.
Правила
-
Поставил звезду — автоматически в деле.
-
Сила измеряется суммарным количеством звезд на ваших личных публичных репозиториях. Форки и организации в расчет не берутся.
-
Лига определяется «возрастом» вашего профиля:
|
Лига |
Возраст аккаунта |
|---|---|
|
Hatchlings |
менее года |
|
Squires |
1–3 года |
|
Knights |
3–7 лет |
|
Ancients |
более 7 лет |
-
Абсолютный лидер лиги забирает её престол. Тот же, кто превосходит всех остальных участников, удостаивается титула Императора.
-
В случае ничьей трон удерживает действующий правитель. Чтобы свергнуть монарха, претенденту необходимо набрать строго больше очков.
-
Отозвали звезду — автоматически отреклись от престола. Корона моментально переходит к следующему претенденту.
-
Ваш аккаунт повзрослел и перешагнул порог лиги — король оставляет прежнее место и переходит в старший дивизион, где придется заново отстаивать право на трон в конкурентной борьбе.
Каждая рокировка во власти бережно фиксируется в летописи, а самые продолжительные эпохи правления навсегда вносятся в Зал славы.
Почему лиги зависят от возраста, а не от звезд
Изначально концепция казалась предельно простой: единственный трон, на котором восседает владелец максимального числа звезд. Однако узкое горлышко проблемы очевидно сразу: как только репозиторий отметит условный мастодонт со 200 тысячами звезд, игровой процесс навсегда заммрится. На этом история бы и закончилась.
Логичное умозаключение — ввести весовые категории по количеству звезд (до 100, до 1000 и т.д.). Но и здесь кроется тупик. Если градации лиг завязаны на звезды, и побеждает внутри них тот, у кого их больше, то вершину каждой лиги неизбежно оккупирует просто обладатель максимального значения для данного порога. Никакой интриги.
Возраст профиля выступает идеальным критерием для категоризации:
-
Отсутствие прямой корреляции с силой. Среди годовых аккаунтов может встретиться как обладатель тысячи звезд, так и новичок с тремя.
-
Абсолютная честность. Искусственно накрутить возраст аккаунта невозможно.
-
Естественная динамика игры. Спустя год любой триумфатор категории Hatchlings неизбежно «взрослеет» и освобождает место. Младшие дивизионы находятся в постоянном движении даже в отсутствие новых участников.
Единственным исключением остается лига Ancients, которая теоретически может «законсервироваться» при появлении признанных легенд индустрии. Но в этом есть своя эстетика: у древних богов свои законы.
Архитектура: полная бессерверность
Технически весь проект держится на одном workflow-файле и компактном Node.js-скрипте, написанном на чистых зависимостях.
звезда → событие watch → GitHub Action → GraphQL API → решение, кто правит → README + SVG-баннер → коммит от github-actions[bot]
Триггер: событие watch
В экосистеме GitHub Actions за звезды отвечает исторически унаследованный хук с неочевидным синтаксисом watch:
on:
watch:
types: [started] # кто-то поставил звезду
schedule:
- cron: '17 * * * *' # ежечасный пересчёт
workflow_dispatch: # ручной запуск
permissions:
contents: write
concurrency:
group: star-throne
cancel-in-progress: false
Директива concurrency критически важна, чтобы предотвратить гонку потоков, когда лавинообразный приток звезд провоцирует параллельные коммиты в README.
Вычисление мощи: GraphQL и алиасы
Первым делом скрипт постранично вытягивает всех старгейзеров репозитория порциями по 100 штук:
repository(owner: $o, name: $n) {
stargazers(first: 100, after: $a, orderBy: {field: STARRED_AT, direction: ASC}) {
pageInfo { hasNextPage endCursor }
edges { starredAt node { login } }
}
}
Далее предстоит рассчитать звездный капитал каждого игрока. Отправлять индивидуальные запросы на каждого юзера — непозволительная роскошь, поэтому я агрегирую пачки по 25 пользователей в единый запрос посредством псевдонимов GraphQL:
const defs = chunk.map((, j) => $l${j}:String!).join(',');
const body = chunk.map((, j) =>
u${j}: user(login: $l${j}) { login createdAt avatarUrl(size: 128) ${REPOS} }
).join('\n');
const { data } = await gql(query(${defs}) { ${body} }, vars);
Тонкий момент оптимизации: репозитории пользователя запрашиваются сразу отсортированными по убыванию звезд. Это позволяет мгновенно прерывать пагинацию, как только исчерпываются значимые проекты. Обладателю трех сотен репозиториев, среди которых звезды есть лишь у пяти, хватит всего одного запроса:
// репозитории отсортированы по звёздам — как только дошли до нуля, дальше смысла нет
if (!page.pageInfo.hasNextPage || !last || last.stargazerCount === 0) break;
Если чей-то профиль оказывается удаленным, GraphQL возвращает по соответствующему алиасу null и генерирует ошибку в массиве errors, не ломая при этом обработку остальных данных. Логика прекрасно справляется с такими частичными сбоями.
Оптимизация кеширования во избежание раздувания репозитория
Регулярно пересчитывать абсолютно всех участников каждый час бессмысленно. Звёзды действующих монархов актуализируются при каждом срабатывании триггера, в то время как рядовые претенденты проверяются раз в сутки. Для этого необходим эффективный механизм кеширования.
Первой идеей было складировать кеш в JSON-файл прямо внутри репозитория. Но поскольку он модифицировался бы ежечасно, история компиляции раздула бы размер git-репозитория на сотни мегабайт за считанные месяцы.
Идеальным решением стало использование встроенного кеша GitHub Actions вместо локальной истории git:
- uses: actions/cache@v4
with:
path: .cache
key: throne-users-${{ github.run_id }}
restore-keys: throne-users-
Уникальный ключ на каждую итерацию в связке с префиксом restore-keys гарантирует подгрузку наиболее актуальной версии кеша. Потеря кеша не критична — скрипт просто бесшовно пересчитает показатели с нуля.
В самом же git хранится исключительно легкий data/throne.json, содержащий информацию о текущих правителях, хронологию и летопись. Он обновляется исключительно в моменты реальных перестановок во власти.
Детерминизм: коммиты только по делу
Хотелось избавиться от ежечасного мусора в истории коммитов, оставив их только для значимых инфоповодов. Для этого визуальное оформление README и баннеров обязано быть максимально стабильным:
-
Сроки правления исчисляются целыми днями («12 days»), игнорируя часы и минуты, благодаря чему README меняется не чаще раза в сутки.
-
Число звезд на графике округляется: формат вроде
★ 212kзащищен от скачков из-за каждой случайно прилетевшей Императору звезды. -
Звездный фон баннера рендерится псевдослучайным генератором с фиксированным сидом (mulberry32), обеспечивая идентичность картинки при каждой генерации.
Результирующий коммит улетает в репозиторий только при условии непустого git diff. Заголовок коммита лаконично отражает суть события:
⚔️ @challenger (★540) overthrew @old-king (★312) and seized the 🛡️ Knights throne after a reign of 3 days.
Таким образом, лента коммитов репозитория трансформируется в увлекательную хронику виртуального королевства.
Баннер: SVG с инлайн-аватарками
Центральный визуальный элемент README представляет собой SVG-документ, генерируемый скриптом «на лету». Здесь кроется архитектурный нюанс: GitHub подгружает SVG через обычный тег , в контексте которого внешние ресурсы строго заблокированы. Прямая ссылка на аватарку пользователя внутри графики просто не отобразится.
Проблема решается предварительной загрузкой аватарок монархов и их последующей инлайн-конвертацией в base64 непосредственно внутри SVG:
const res = await fetch(user.avatarUrl);
const type = res.headers.get('content-type') || 'image/png';
return data:${type};base64,${Buffer.from(await res.arrayBuffer()).toString('base64')};
Аватары маскируются под круги посредством clipPath, поверх отрисовывается корона из векторного примитива polygon. Пять аватаров разрешением 128 пикселей весят суммарно около 16 КБ — отличный показатель производительности.
Подводные камни и баги
Проблема № 1: сломанный синтаксис из-за < 1 year
Самый первый рендер баннера завершился крахом с ошибкой StartTag: invalid element name. Виновницей оказалась подпись лиги Hatchlings — account < 1 year. Поскольку SVG по своей сути является XML-разметкой, неэкранированный знак < воспринимается парсером как открытие тега, ломая отображение всей графики ниже.
Вывод очевиден: при программной генерации XML-структур экранированию подлежит абсолютно весь контент, а не только динамические поля ввода.
Проблема № 2: монарх, правивший 14 минут
Первопроходцем проекта стал смельчак с нулевым балансом звезд. За неимением конкурентов он мгновенно узурпировал сразу два титула: Squires и Императорский престол.
👑 @first-king claimed both the empty 🗡️ Squires throne and the 👑 Imperial crown with ★0.
Спустя пару минут он отозвал свою звезду. Но трон остался за ним.
Выяснилось удивительное: событие watch триггерится исключительно в момент установки звезды (started). GitHub попросту не шлет уведомлений об отмене звездочки. Единственный способ зафиксировать отречение — регулярный аудит текущего списка старгейзеров. Именно эту задачу и решает ежечасный фоновый cron. Когда отработал следующий плановый запуск, летопись пополнилась исторической записью:
🏳️ @first-king abdicated both the 🗡️ Squires throne and the 👑 Imperial crown after 14 min by unstarring the realm. The throne stands empty.
Самое короткое правление в истории королевства продлилось ровно 14 минут. Потрясающий фундамент для лора игры.
Стоит учитывать еще один фактор: планировщик GitHub Actions может задерживать выполнение задач на десятки минут, особенно на свежих репозиториях, поэтому выход игрока из битвы фиксируется не мгновенно, а в течение часа.
Проблема № 3: упрямое кеширование картинок
После первой коронации текстовая таблица в README обновилась корректно, однако на самом баннере троны упорно отображались пустыми. Инспекция SVG показала, что файл в репозитории сгенерирован верно и содержит актуальные никнеймы с аватарами.
Виной всему оказался браузерный кеш. Баннер извлекается через raw.githubusercontent.com с заголовком ответа Cache-Control: max-age=300, а запрашиваемый URL неизменен — assets/throne.svg.
Проблема решается классическим приемом cache busting: к ссылке динамически дописывается хеш актуального содержимого файла.
return createHash('sha1').update(svg).digest('hex').slice(0, 10);
// → 
Изменилось содержимое графика — изменился и путь к файлу, заставив браузер подгрузить свежую графику. Обошлось без лишних правок контента.
Проблема № 4: дублирующиеся уведомления
Лидер лиги Ancients, как правило, одновременно носит корону Императора. При каждой рокировке логов летопись выдавала две практически идентичные строки: «X сверг Y на троне Ancients» и «X сверг Y на Императорском троне». Теперь подобные события интеллектуально объединяются в единое сообщение: «X сверг Y и захватил и трон Ancients, и Императорскую корону».
О честности состязания
Самый частый вопрос, который мне задают: «А что мешает искусственно накрутить звезды?» Сам по себе скрипт не занимается премодерацией, но алгоритмы безопасности GitHub отрабатывают безупречно: ботоводческие фермы и купленные звезды быстро вычисляются и скрываются платформой. Трон, завоеванный нечестным путем, долго не простоит.
При этом привлечь медийного друга ради свержения действующего монарха — это не читерство, а квинтэссенция игровой механики.
Итоги
-
Минималистичный стек: один воркфлоу и скрипт на Node.js объемом около 450 строк кода без сторонних зависимостей.
-
Нулевые затраты финансов и времени: инфраструктура GitHub Actions для публичных репозиториев полностью бесплатна.
-
Автономная генерация всей сопутствующей истории: летописи, Зала славы и династических хроник.
Исходный код проекта распространяется под лицензией MIT. Если вы хотите развернуть собственное виртуальное королевство для команды, компании или гильдии — просто сделайте форк. Кастомизация лиг, названий дивизионов и цветовой палитры вынесена в файл throne.config.json.
На данный момент единоличным правителем королевства остается игрок, удерживающий трон Knights и Императорский титул всего с одной честно заработанной звездой. Остальные престолы пока вакантны. Актуальный расклад сил и летопись доступны по ссылке: github.com/Lzeray/star‑throne.
Буду искренне рад вашим вопросам, конструктивной критике архитектуры и игровых механик в комментариях. Особенно любопытно узнать, какие еще нестандартные интерактивные проекты можно реализовать на базе одних лишь GitHub Actions без использования бэкенда.
В Gears of War E Day город будет разрушаться прямо по ходу кампании
В Японии полностью раскупили PS5 Pro несмотря на лотерейную систему от Sony
Джеймс Ганн уточнил, что сериал Народ против Гориллы Гродда выйдет после Человека завтрашнего дня
Предзаказ на линейку акриловых красок для миниатюр Warhammer Tone Pro откроется 10 октября
Продюсер Dynasty Warriors больше не хочет заниматься ремастерами серии
Sony ищет сторонние идеи для возрождения забытых франшиз PlayStation
Фильм о Клэйфейсе может показать на старте результат хуже провальной Супергёрл, но для DC Studios это хорошие новости
Стал доступен предзаказ на контроллер Mobapad M12 Pro для Switch 2