Создатель 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 без использования бэкенда.
Джейсон Шрейer сообщил, что Star Wars Jedi 3 находится на продвинутой стадии разработки
Создатели Path of Exile 2 пообещали раскрыть подробности релизной версии 1.0 через несколько дней
Ayaneo анонсировала AYASpace 2 Pocket Museum под названием «карманный музей» для портативных консолей
Phantom Blade Zero стала главным хитом Steam, а в Китае объявили войну западным AAA-играм
Эхо минувшего. Часть 2. 30 фактов об игровой индустрии за 3 декабря 1994 года +
Энтузиаст перенес The Elder Scrolls V: Skyrim на Android без участия Bethesda
Для Crimson Desert выпустили первый хотфикс из-за сбоев после крупного обновления 2.04.00
Для ремастера The Witcher 3 на ПК вышло обновление с исправлением ошибок и улучшениями