Трон для самого звёздного: я сделал игру, которая живёт внутри GitHub‑репозитория
- пятница, 2 октября 2026 г. в 00:00:12

Помните Million Dollar Homepage? В 2005 году студент продал миллион пикселей по доллару за штуку и заработал миллион. Никакой пользы в этих пикселях не было, но люди платили за участие в мемчике и за место, которое видят другие.
Мне захотелось сделать что‑то похожее для разработчиков, только вместо денег — валюта, которую у нас и так все копят: звёзды на GitHub.
Так появился Star Throne — репозиторий‑игра. Ставишь звезду на репо, и через минуту‑две GitHub Actions считает, сколько звёзд у тебя самого. Если ты сильнейший в своей лиге, твоя аватарка появляется на троне прямо в README. Сервера нет, базы данных нет, хостинга нет. Всё работает на GitHub.
Ниже расскажу, как это устроено, почему лиги пришлось делить по возрасту аккаунта, а не по звёздам, и на какие грабли я наступил в первую же ночь.
Поставил звезду — ты в игре.
Сила — сумма звёзд на твоих собственных публичных репозиториях. Форки и организации не считаются.
Лига определяется возрастом аккаунта:
Лига | Возраст аккаунта |
|---|---|
Hatchlings | меньше года |
Squires | 1–3 года |
Knights | 3–7 лет |
Ancients | больше 7 лет |
Сильнейший в лиге получает её трон. Сильнейший среди всех — титул Императора.
При равенстве звёзд трон остаётся у нынешнего короля. Чтобы свергнуть, нужно строго больше.
Снял звезду — отрёкся. Корона уходит следующему.
Аккаунт повзрослел и вышел за границу лиги — король оставляет трон и уходит в следующую лигу, где придется сражаться за трон уже с другими.
Каждая смена власти записывается в летопись, а самые долгие правления — в Зал славы.
Первая версия идеи была простой: один трон, на нём человек с максимумом звёзд. Проблема видна сразу: как только звезду поставит кто‑нибудь с 200 тысячами звёзд, трон замрёт навсегда. Игра закончится в тот же день.
Очевидное решение — весовые категории по звёздам: до 100, до 1 000 и так далее. Но это не работает. Если лига определяется звёздами, а внутри лиги побеждает тот, у кого звёзд больше, то король каждой лиги — это просто человек у верхней границы. Рейтинг по тому же параметру, по которому делят на лиги, ничего не решает.
Возраст аккаунта — хорошая ось для лиг:
Он не связан с силой напрямую. Среди аккаунтов младше года может оказаться человек с тысячей звёзд, а может — с тремя.
Он честный. Возраст аккаунта не накрутишь.
Он сам двигает игру. Через год любой король Hatchlings неизбежно «повзрослеет» и освободит трон. В младших лигах власть меняется сама собой, даже если никто новый не пришёл.
Только лига Ancients рискует застыть, если туда придёт кто‑то из легенд. Но это даже красиво: у древних свои боги.
Вся игра — это один workflow и один скрипт на Node.js без зависимостей.
звезда → событие watch → GitHub Action → GraphQL API → решение, кто правит → README + SVG-баннер → коммит от github-actions[bot]
В GitHub Actions есть событие с неочевидным названием watch. Исторически «watch» на GitHub означало звезду, и название так и осталось:
on: watch: types: [started] # кто-то поставил звезду schedule: - cron: '17 * * * *' # ежечасный пересчёт workflow_dispatch: # ручной запуск permissions: contents: write concurrency: group: star-throne cancel-in-progress: false
concurrency нужен, чтобы два запуска одновременно не пытались закоммитить разные версии README, если звёзды ставят пачкой.
Сначала скрипт забирает всех старгейзеров репозитория, по 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);
Ещё одна мелочь: репозитории пользователя я запрашиваю отсортированными по звёздам по убыванию. Поэтому листать страницы можно, только пока звёзды не кончились. У человека с 300 репозиториями, из которых звёзды есть у пяти, хватит одного запроса:
// репозитории отсортированы по звёздам — как только дошли до нуля, дальше смысла нет 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»), а не «12d 4h 17m». README меняется максимум раз в сутки.
Звёзды на баннере округляются: ★ 212k не меняется от каждой новой звезды у Императора.
Звёздное небо на фоне баннера рисуется генератором случайных чисел с фиксированным зерном (mulberry32), так что картинка одинаковая при каждом запуске.
Коммит делается, только если git diff не пустой. Заголовок коммита — сама новость:
⚔️ @challenger (★540) overthrew @old-king (★312) and seized the 🛡️ Knights throne after a reign of 3 days.
История коммитов репозитория превращается в хронику королевства.
Главная картинка README — это SVG, который скрипт генерирует заново. Тут есть тонкость: GitHub показывает SVG через тег <img>, а SVG в режиме картинки не может загружать внешние ресурсы. Ссылка на аватарку внутри SVG просто не загрузится.
Поэтому скрипт скачивает аватарки правителей и встраивает их прямо в SVG в base64:
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 КБ, вполне терпимо.
Первая отрисовка баннера показала лишь половину картинки и ошибку StartTag: invalid element name. Причина: подпись лиги Hatchlings — account < 1 year. В SVG, который является XML, символ < открывает тег. Одна неэкранированная подпись — и браузер отказывается рисовать всё, что ниже.
Мораль: в генерируемом XML экранировать нужно всё, а не только пользовательский ввод.
Первым участником стал человек с нулём звёзд (ник я поменял). Раз других претендентов не было, он получил сразу два трона: 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 может задерживать на десятки минут, особенно в новых репозиториях. Выход из игры фиксируется не мгновенно, а в течение часа.
После первой коронации таблица в README обновилась, а на баннере троны оставались пустыми. Я полез проверять SVG — в репозитории он был правильный, с ником и аватаркой.
Дело было в кеше. Картинка из README отдаётся через raw.githubusercontent.com с заголовком Cache-Control: max-age=300, а адрес у неё всегда один и тот же: assets/throne.svg. Браузер честно показывал старую версию.
Решение — классический cache busting: к адресу добавляется хеш содержимого.
return createHash('sha1').update(svg).digest('hex').slice(0, 10); // → <img src="assets/throne.svg?v=e9c6a25460">
Картинка поменялась — поменялся адрес, и браузер загружает свежую версию. Картинка не менялась — адрес прежний, и лишнего коммита нет.
У самого сильного игрока лиги Ancients обычно есть и корона Императора. При каждой смене власти летопись писала две почти одинаковые строки: «X сверг Y на троне Ancients» и «X сверг Y на Императорском троне». Теперь такие события склеиваются в одно: «X сверг Y и захватил и трон Ancients, и Императорскую корону».
Первый вопрос, который мне задали: «А если накрутить звёзды?» Скрипт никого не проверяет, но GitHub проверяет: за купленные звёзды и ботов аккаунты помечают и скрывают. Так что трон, завоёванный накруткой, долго не простоит.
А вот позвать знаменитого друга, чтобы он свергнул нынешнего короля, — это не читерство, а весь смысл игры.
Один workflow‑файл и один скрипт на Node.js, около 450 строк, без зависимостей.
Ноль серверов и ноль рублей в месяц: Actions для публичных репозиториев бесплатны.
Летопись, Зал славы и императорская династия пишутся сами.
Код открыт под MIT. Если хотите своё королевство — для команды, компании или сообщества, — сделайте форк. Лиги, названия и цвета меняются в throne.config.json.
Сейчас в королевстве правит один человек: он занял трон Knights и заодно стал Императором, имея ровно одну звезду. Остальные троны пустуют. Посмотреть, кто правит сейчас, и почитать летопись можно в репозитории: github.com/Lzeray/star‑throne.
Буду рад вопросам и критике в комментариях: и по архитектуре, и по правилам игры. Особенно интересно, какие ещё механики можно построить на одних GitHub Actions без сервера.