Игра открывается внутри платформы с уже подписанной личностью игрока — ни регистрации, ни своего логина не нужно. Отсюда доступны профиль, друзья, публикация результата и платежи.
Нужен мультиплеер, чат, сохранения, рекорды или игровая экономика — они тоже готовы: Zloy Backend, свой сервер писать не придётся.
Платформа подписывает игрока и передаёт его игре в адресе страницы.
Игра открывается на странице zloy.net/<адрес_игры>
внутри iframe. К адресу платформа добавляет четыре параметра —
подписанную личность игрока:
app_id | идентификатор вашей игры |
user_id | идентификатор игрока, открывшего игру |
ts | unix-время выдачи подписи (действует 48 часов) |
sign | HMAC-SHA256(app_key, "app_id:user_id:ts") в hex |
Эти параметры передаются в каждый запрос без изменений. Подделать
подпись без app_key нельзя; ключ виден только вам на
странице редактирования игры, там же его можно отозвать — старые
подписи мгновенно перестают действовать.
// Адрес, по которому открылась игра:
// https://ваш-адрес/?app_id=1&user_id=42&ts=1760000000&sign=<hex>
// SDK подхватывает параметры сам — руками их читать не нужно
const p = new URLSearchParams(location.search);
console.log('игрок', p.get('user_id'));
interface Identity {
app_id: string; user_id: string; ts: string; sign: string;
}
const p = new URLSearchParams(location.search);
const identity: Identity = {
app_id: p.get('app_id') ?? '',
user_id: p.get('user_id') ?? '',
ts: p.get('ts') ?? '',
sign: p.get('sign') ?? '',
};
Отдельное приложение авторизуется само — через системный браузер, без пароля внутри игры.
Когда игра собрана отдельным приложением — .exe,
сборка под Linux, APK для RuStore, Huawei, Xiaomi или Samsung —
адреса страницы с подписью нет: подписать личность игрока некому.
Ключ игры app_key в сборку класть нельзя — его
достанут из любого пакета.
Поэтому вход идёт по схеме OAuth 2.0 Authorization Code + PKCE (RFC 7636 и 8252): игра открывает zloy.net в системном браузере, игрок подтверждает вход на нашем домене, браузер возвращает в игру одноразовый код, и уже код меняется на токены. Пароль игра не видит никогда, а перехваченный код бесполезен без секрета, который остался у игры в памяти.
Всё это делает SDK: вам нужен один вызов
await Zloy.login(). Он же сам определит, что игра
запущена внутри платформы, и тогда не покажет игроку ничего.
| Возврат в игру | http://127.0.0.1:<любой порт>/… — работает на всех платформах и ничего не требует от сборки: ни intent-filter, ни схемы в реестре Windows. Дополнительно разрешена схема zloy.<адрес игры>://… |
| Если браузер не вернулся | await Zloy.login_by_code(): игра показывает шесть символов, игрок вводит их на zloy.net/link |
| Повторные запуски | вход не требуется: SDK хранит долгий токен в user:// и обновляет доступ сам |
| Отзыв доступа | игрок в любой момент выходит из игры в разделе Вход и безопасность |
| Один аккаунт — много игр | сессия живёт в системном браузере, поэтому во вторую игру Zloy игрок входит одним нажатием |
app_id и адрес игры платформа кладёт в пакет сама
(файл zloy_client.json, появляется после первой
публикации) — добавлять его в проект не нужно.
// 1. открыть в системном браузере
GET https://zloy.net/oauth/authorize
?response_type=code
&client_id=<app_id>
&redirect_uri=http://127.0.0.1:<порт>/zloy
&code_challenge=<base64url(sha256(verifier))>
&code_challenge_method=S256
&state=<случайное>
// 2. браузер вернёт ?code=…&state=… на локальный адрес
// 3. обменять код на токены
POST https://zloy.net/oauth/token
grant_type=authorization_code&code=…&code_verifier=…
&redirect_uri=…&client_id=…
→ {ok, access_token, expires_in, refresh_token,
user, app_id, slug, api_base, ws_url}
// 4. дальше: методы API — с Authorization: Bearer <access_token>,
// кубики — wss://api.zloy.net/rt/ws?access_token=…
// обновление доступа (старый refresh при этом гасится)
POST /oauth/token grant_type=refresh_token&refresh_token=…
// выход
POST /oauth/revoke token=<refresh_token>
// вход по коду с экрана
POST /oauth/device client_id=<app_id>
→ {device_code, user_code, verification_uri, interval}
POST /oauth/token
grant_type=urn:ietf:params:oauth:grant-type:device_code
&device_code=…
Одна строка в игре — и вызовы уже подписаны.
JavaScript SDK добавляет подписанные параметры в каждый вызов сам.
Методы возвращают промис с разобранным JSON:
{ok: true, …} либо {ok: false, error: "…"}.
Если SDK не подходит, те же методы доступны обычным
fetch — строка запроса та же самая.
Базовый адрес: https://api.zloy.net/v1
(зеркало https://zloy.net/api/v1). Все методы — GET,
ответы JSON, CORS открыт.
<script src="https://zloy.net/assets/zloy-sdk.js"></script>
<script>
const me = await zloy.getUserInfo();
console.log('привет,', me.user.nickname);
const friends = await zloy.getUserFriends(0, 100);
console.log(friends.total, friends.friends);
</script>
import 'https://zloy.net/assets/zloy-sdk.js';
interface User {
user_id: number; username: string; nickname: string; avatar: string;
}
interface Self extends User {
lang: 'ru' | 'en'; // язык аккаунта — только у самого игрока
}
interface UserInfo { ok: boolean; user: Self }
interface FriendsPage {
ok: boolean; total: number; offset: number; limit: number; friends: User[];
}
declare const zloy: {
getUserInfo(): Promise<UserInfo>;
getUserFriends(offset?: number, limit?: number): Promise<FriendsPage>;
getUserFriendsInApp(offset?: number, limit?: number): Promise<FriendsPage>;
getToken(): Promise<{ok: boolean; token: string; expires_in: number}>;
postResult(text: string): Promise<{ok: boolean; url: string; seq: number}>;
sendToWall(opts: {text?: string; image?: HTMLCanvasElement | Blob | File;
video?: Blob | File}):
Promise<{ok: boolean; url: string; seq: number; expires_at: string;
media_status?: string; error?: string; retry_after?: number}>;
pay(description: string, amount: number): Promise<{ok: boolean; error?: string}>;
rt: RealtimeApi; // Zloy Backend, см. /docs/backend
};
const me: UserInfo = await zloy.getUserInfo();
Одинаковая во всех методах. nickname — отображаемое имя,
username — адрес страницы игрока
(zloy.net/<username>), avatar —
абсолютная ссылка либо пустая строка.
У самого игрока (getUserInfo)
есть ещё lang — язык его аккаунта на платформе,
"ru" или "en". Открывайте игру на нём, а не
по языку браузера: человек уже выбрал язык на zloy.net, и в игре
ожидает тот же. В списках друзей поля нет — чужой язык игре не нужен.
{
"user_id": 42,
"username": "zloysega",
"nickname": "Сергей",
"avatar": "https://zloy.net/uploads/avatars/u42-abc123.png",
"lang": "ru" // только в getUserInfo
}
interface User {
user_id: number;
username: string;
nickname: string;
avatar: string; // '' если аватар не задан
}
interface Self extends User {
lang: 'ru' | 'en'; // язык аккаунта; есть только у самого игрока
}
Обязательные параметры каждого запроса —
app_id, user_id, ts, sign.
getUserInfoGET#Профиль игрока, открывшего игру. Спрашивать его при старте — нормальная практика: имя, аватар и язык обычно нужны сразу.
lang — язык аккаунта игрока ("ru" или
"en"). Это его выбор на платформе, поэтому он точнее
языка браузера и системы: включайте локализацию игры по нему.
const {user} = await zloy.getUserInfo();
nameLabel.textContent = user.nickname;
if (user.avatar) avatarImg.src = user.avatar;
setLocale(user.lang); // 'ru' | 'en' — выбор игрока на платформе
// Ответ:
// { "ok": true, "user": { "user_id": 42, "username": "zloysega",
// "nickname": "Сергей", "avatar": "…", "lang": "ru" } }
const {user}: UserInfo = await zloy.getUserInfo();
render(user);
i18n.locale = user.lang; // 'ru' | 'en'
getUserFriendsGET#Друзья игрока с пагинацией: offset (по умолчанию 0)
и limit (по умолчанию и максимум 100). В ответе есть
total — сколько всего.
const {total, friends} = await zloy.getUserFriends(0, 100);
console.log(`друзей: ${total}`);
// Ответ:
// { "ok": true, "total": 152, "offset": 0, "limit": 100,
// "friends": [ { "user_id": 7, "nickname": "Алиса", … } ] }
const page: FriendsPage = await zloy.getUserFriends(0, 100);
page.friends.forEach(renderFriend);
getUserFriendsInAppGET#То же самое, но только друзья, которые уже установили вашу игру. Годится для «позови своих» и для показа, с кем можно сыграть прямо сейчас.
const {friends} = await zloy.getUserFriendsInApp();
showInviteList(friends);
const {friends}: FriendsPage = await zloy.getUserFriendsInApp();
getUserInstalledAppsGET#Игры, которые игрок установил. Пагинация как у друзей. Полезно для кросс-промо между вашими играми.
const {apps} = await zloy.call('getUserInstalledApps');
// Ответ:
// { "ok": true, "total": 3, "offset": 0, "limit": 100,
// "apps": [ { "app_id": 1, "slug": "darkrunner", "name": "Dark Runner",
// "icon": "https://zloy.net/uploads/appicons/a1-abc.png",
// "summary": "Бегай во тьме.",
// "url": "https://zloy.net/darkrunner" } ] }
interface App {
app_id: number; slug: string; name: string;
icon: string; summary: string; url: string;
}
interface AppsPage { ok: boolean; total: number; apps: App[] }
postResultGET#Публикует результат на стене самого игрока: запись появляется по
адресу zloy.net/<игрок>/<seq>, где
seq — номер записи в пределах страницы игрока
(поэтому zloy.net/alice/7 и zloy.net/bob/7
— разные записи). Автор записи — игрок; вместе с ней сохраняется
IP запроса (требование закона о соцсетях).
text — до 280 символов, обязателен. Публиковать можно
через неделю после регистрации игрока, иначе ответ
posting_locked. Часовой лимит на игрока защищает от
спама — rate_limited.
const r = await zloy.postResult(`Набрал ${score} очков!`);
if (r.ok) window.open(r.url, '_blank');
// Ответ:
// { "ok": true, "post_id": 812, "seq": 34,
// "url": "https://zloy.net/alice/34" }
const r: {ok: boolean; post_id: number; seq: number; url: string} =
await zloy.postResult(`Набрал ${score} очков!`);
sendToWallPOST#Кладёт достижение на страницу самой игры
(zloy.net/<адрес_игры>game): таблицу результатов
в markdown и снимок или короткий ролик. Запись подписана
игроком, от чьего имени пришёл вызов, поэтому она же появляется в
ленте его друзей. Медиа попадает в галерею страницы.
text — markdown до 4000 символов,
image — canvas, Blob или
File: принимаем png, jpeg, gif, bmp, webp
(до 4 МБ, сторона до 1920). video —
Blob или File в любом формате,
который читает сервер (webm с MediaRecorder, mp4,
mov, mkv): до 200 МБ и до 180 секунд. Жать перед
отправкой не нужно — сожмём мы.
Снимок или ролик, но не оба сразу (иначе
image_and_video): в карточке поста место под медиа
одно, и выбирать за вас, что показать, платформа не станет. Нужно
хотя бы одно из трёх — текст, снимок или ролик.
Хранится снимок всегда как JPEG, чем бы вы его ни прислали: кадр из игры — фотография по природе, и PNG держал бы её впятеро дороже без разницы на глаз. Прозрачность при этом теряется — то, что было прозрачным, станет чёрным. Ролик — всегда MP4 720p (H.264 + AAC): игра пишет тем, что есть под рукой, а смотрят пост с чужого телефона по ссылке из ленты, и второй попытки у него не будет. 1080p и 4K уменьшаются до 720p — в ленте разницы не видно, а весит втрое меньше.
Ролик перекодируется в очереди, и запись появляется
раньше него. Ответ приходит сразу и несёт
media_status: "processing"; сам ролик доезжает к
записи через десятки секунд, а до тех пор на странице написано,
что он готовится. Держать ваш вызов всё это время платформа не
станет: минута ожидания — это замерший экран у игрока и оборванный
по таймауту запрос у вас. Отказы, которые видны сразу (не видео,
слишком длинный, слишком большой), приходят обычной ошибкой — их
ждать не нужно.
Медиа на zloy.net выкладывает только игра. Человеку
загрузка закрыта — поток произвольных картинок и роликов нечем
модерировать; за то, что кладёт игра, она отвечает своим ключом.
Файлы раздаются с отдельного домена s.zloy.net.
Водяной знак платформа ставит сама — и на снимок, и на ролик: чёрную плашку с иконкой и адресом вашей игры в левом нижнем углу (высота — 3% кадра). Пост уходит в ленту друзей и дальше по пересылкам, и метка — единственное, что приводит по нему обратно в игру. Рисовать свою не нужно, а вот левый нижний угол кадра лучше держать свободным: счётчик или кнопка там окажутся под плашкой.
В ответе — адрес ЗАПИСИ, а не файла. Публикуется запись:
у неё адрес, срок, реакции и комментарии, и именно её показывают
игроку. Полей image_url и video_url
больше нет — у ролика в момент ответа файла ещё и не существует.
Запись живёт 30 суток, дальше платформа удаляет и её, и
файл (срок возвращается в expires_at). Страница игры
— не архив: таблица прошлого сезона вытесняет сегодняшнюю.
Частота — пост в час и не больше 12 в сутки на игрока в этой
игре. Лимит принадлежит игре: сколько игрок постил в других,
вашей не касается, и наоборот. Перебор —
rate_limited вместе с retry_after в
секундах; повторять раньше бессмысленно. Это не поломка: покажите
«поделиться можно будет позже», а не ошибку. Если у игры ещё нет
промо-страницы, ответ — no_page: постить некуда.
const r = await zloy.sendToWall({
text: `**Сезон закрыт!**\n\n| место | игрок | очки |\n|---|---|---|\n| 1 | Алиса | 9120 |`,
image: document.querySelector('canvas'), // canvas | Blob | File
});
if (r.ok) console.log(r.url, 'исчезнет', r.expires_at);
else if (r.error === 'rate_limited') laterMsg(r.retry_after);
// Ответ — про ЗАПИСЬ, а не про файл:
// { "ok": true, "seq": 41,
// "url": "https://zloy.net/darkrunnergame/41",
// "expires_at": "2026-09-16T21:03:11Z" }
// РОЛИК — вместо снимка. Записать его прямо с холста игры:
const stream = document.querySelector('canvas').captureStream(30);
const rec = new MediaRecorder(stream, { mimeType: 'video/webm' });
const parts = [];
rec.ondataavailable = (e) => parts.push(e.data);
rec.start();
setTimeout(() => rec.stop(), 8000); // 8 секунд трюка
rec.onstop = async () => {
const clip = new Blob(parts, { type: 'video/webm' });
const r = await zloy.sendToWall({ text: 'Вот это проход!', video: clip });
// { "ok": true, "seq": 42, "url": "https://zloy.net/darkrunnergame/42",
// "expires_at": "…", "media_status": "processing" }
// Ссылку можно показывать сразу: запись уже есть, ролик подтянется к ней.
};
interface WallPost {
ok: boolean; seq: number; url: string;
expires_at: string;
media_status?: 'processing'; // ролик ещё перекодируется
error?: 'no_page' | 'empty' | 'text_too_long' | 'image_too_large'
| 'image_and_video' | 'video_too_large' | 'video_too_long'
| 'bad_video' | 'video_unsupported'
| 'rate_limited' | 'posting_locked' | 'banned';
retry_after?: number; // секунды, только при rate_limited
}
const r: WallPost = await zloy.sendToWall({ text: 'Рекорд!' });
payпо допуску#SDK показывает оверлей «Пополнить игру» с описанием и суммой в
рублях; после подтверждения создаётся платёж
(GET /v1/createPayment?amount=<₽>&description=…)
и браузер переходит по полученному url в платёжную
систему.
Платежи доступны ограниченному списку игр. Если игра не в
списке, вызов тихо возвращает
{ok: false, error: 'payments_not_allowed'} и ничего не
показывает. Проверка допуска —
GET /v1/paymentsAllowed →
{"ok":true,"allowed":true|false}.
Куда зачислять купленное (какой банк и валюта) настраивается в свойствах игры — см. кубик «Банк».
const r = await zloy.pay('100 кристаллов', 199);
// r.ok === true → игрок подтвердил, идёт переход в платёжную систему
// r.error === 'payments_not_allowed' | 'cancelled'
const r: {ok: boolean; error?: 'payments_not_allowed' | 'cancelled'} =
await zloy.pay('100 кристаллов', 199);
Нужна, только если у игры есть собственный бэкенд.
Пересчитайте подпись своим app_key — сойдётся, значит
user_id, пришедшему от клиента, можно доверять.
Не хотите держать app_key на стороннем сервере —
используйте токен личности: там проверка
публичным ключом платформы, общий секрет не нужен.
import hmac, hashlib
def valid(app_id, user_id, ts, sign, app_key):
want = hmac.new(app_key.encode(),
f"{app_id}:{user_id}:{ts}".encode(),
hashlib.sha256).hexdigest()
return hmac.compare_digest(want, sign)
import {createHmac, timingSafeEqual} from 'node:crypto';
function valid({app_id, user_id, ts, sign}, appKey) {
const want = createHmac('sha256', appKey)
.update(`${app_id}:${user_id}:${ts}`).digest('hex');
return want.length === sign.length &&
timingSafeEqual(Buffer.from(want), Buffer.from(sign));
}
func valid(appID, userID, ts int64, sign, appKey string) bool {
mac := hmac.New(sha256.New, []byte(appKey))
fmt.Fprintf(mac, "%d:%d:%d", appID, userID, ts)
want := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(want), []byte(sign))
}
Для своего бэкенда, которому вы не доверяете app_key.
zloy.getToken() делает
POST https://zloy.net/studio/v1/token с теми же
подписанными параметрами и возвращает
{"ok":true,"token":"<jwt>","expires_in":300} — JWT
(EdDSA/Ed25519) на 5 минут. SDK кеширует его и обновляет сам.
Claims: iss="https://zloy.net", sub — id
игрока строкой, aud — адрес (slug) вашей игры,
iat/exp, nickname.
Ваш сервер проверяет подпись публичным ключом платформы:
GET https://zloy.net/.well-known/jwks.json (JWKS, ключ
OKP/Ed25519, alg="EdDSA", стабильный
kid). Проверяйте подпись, exp,
iss и aud.
Для мультиплеера на платформе токен не нужен: кубики проверяют личность сами.
// клиент: получить токен и отдать своему серверу
const {token} = await zloy.getToken();
socket.send(JSON.stringify({auth: token}));
# pip install pyjwt[crypto] requests
import jwt, requests
jwks = requests.get("https://zloy.net/.well-known/jwks.json").json()
key = jwt.PyJWK.from_dict(jwks["keys"][0]).key
claims = jwt.decode(token, key=key, algorithms=["EdDSA"],
issuer="https://zloy.net",
audience="адрес-вашей-игры")
user_id = int(claims["sub"])
import {createRemoteJWKSet, jwtVerify} from 'jose';
const jwks = createRemoteJWKSet(
new URL('https://zloy.net/.well-known/jwks.json'));
const {payload} = await jwtVerify(token, jwks, {
issuer: 'https://zloy.net',
audience: 'адрес-вашей-игры',
});
const userId = Number(payload.sub);
Ответ всегда JSON; ошибка — {"ok": false, "error": "код"}:
bad_request | не хватает параметров |
expired | подпись старше 48 часов — перезагрузите страницу игры |
unknown_app | неизвестный app_id |
bad_sign | подпись не сошлась (например, ключ отозван) |
unknown_user | игрок не найден |
posting_locked | аккаунт моложе недели (postResult, sendToWall) |
rate_limited | слишком часто; у sendToWall рядом лежит retry_after — через сколько секунд пустят |
no_page | у игры нет промо-страницы (только sendToWall) |
image_and_video | прислали и снимок, и ролик: место под медиа в посте одно — выберите что-то одно (sendToWall) |
video_too_large | ролик больше 200 МБ |
video_too_long | ролик длиннее 180 секунд |
bad_video | под видом ролика приехало не видео — формат определяется разбором, а не именем файла |
video_unsupported | приём роликов сейчас недоступен на стороне платформы; снимок в этот момент работает — это осмысленный запасной путь |
banned | разработчик игры закрыл этому игроку доступ — повтор не поможет, покажите заглушку |
Вкладку с игрой держат открытой сутками, а подпись живёт 48 часов —
обработайте expired предложением обновить страницу, а не
общей ошибкой.
const r = await zloy.getUserInfo();
if (!r.ok) {
if (r.error === 'expired') askReload();
else console.warn('API:', r.error);
}
type ApiError = 'bad_request' | 'expired' | 'unknown_app'
| 'bad_sign' | 'unknown_user' | 'posting_locked' | 'rate_limited';
const r = await zloy.getUserInfo();
if (!r.ok) handle((r as {error: ApiError}).error);
limit в списках — максимум 100 за запрос.postResult: до 280 символов, не раньше недели с
регистрации игрока, с часовым лимитом.sendToWall: markdown до 4000 символов, картинка до
4 МБ либо ролик до 200 МБ и 180 секунд (не оба сразу);
пост в час и 12 в сутки на игрока в этой игре
(лимит у каждой игры свой); запись живёт 30 суток.app_key не должен попадать в код игры: подписи
выдаёт платформа, ключ живёт только на сервере.s.zloy.net —
чужой код не делит origin с сессией сайта.К адресу игры можно дописать ?start=… —
https://zloy.net/supergame?start=234678. Значение
доезжает до игры, и по нему она открывает то место, ради которого
ссылку и прислали: ферму друга, комнату, приглашение.
Это единственный параметр адреса, который игра получает. Остальные платформа отбрасывает: пробросить их значило бы дать приславшему ссылку дописать туда подпись личности игрока или флаг черновика.
Значение проверяет платформа: латинские буквы, цифры,
., -, _, не длиннее 64
символов. Всё остальное молча отбрасывается — игра получит пустую
строку, а не ошибку: по ссылке переходит человек, и «неверный
параметр» вместо игры было бы худшим ответом.
Проверять значение всё равно обязана игра. Платформа отвечает только за то, что в нём нет опасных символов, — но не за то, что такая ферма или комната у вас есть. Ссылку пришлёт кто угодно и с чем угодно внутри разрешённого набора.
if (zloy.start) {
const room = rooms.find(r => r.id === zloy.start); // ПРОВЕРЯЕМ, что такая есть
if (room) enterRoom(room); else showLobby();
} else {
showLobby();
}