File SDK chính là một client của giao thức này. Hãy dùng SDK nếu có thể: nó kiểm tra tham số, gom sự kiện và lưu dữ liệu, tự rơi về standalone, chống trùng pause/resume. Chỉ tự cài giao thức khi môi trường của bạn không nạp được file SDK (ví dụ một runtime tự quản lý vòng lặp sự kiện của nó) hoặc khi viết một client cho ngôn ngữ khác.
Phát hiện portal
Portal mở game bằng URL index.html?wsg=1&host=<origin portal>&lang=<vi|en>&v=<version> (thêm wsg_test=1 ở chế độ test). Game nói chuyện với portal khi đủ ba điều kiện:
- Đang ở trong iframe:
window.parent !== window. - Query
wsgbằng1. - Query
hostlà một originhttphoặchttpshợp lệ.
Sau đó gửi yêu cầu init. Không nhận được trả lời trong 3 giây thì coi như không có portal và chạy chế độ standalone của riêng bạn.
Envelope
interface BridgeMessage {
wsg: 1; // phiên bản giao thức
kind: 'req' | 'res' | 'evt';
id?: string; // bắt buộc với req và res
type: string; // tên yêu cầu hoặc sự kiện
payload?: unknown;
error?: { code: string; message: string }; // chỉ có trong res thất bại
}- Game → portal:
kind: 'req', mỗi yêu cầu mộtidriêng (ví dụr1,r2), gửi bằngwindow.parent.postMessage(msg, host). - Portal → game:
kind: 'res'cùngidvàtypevới yêu cầu; thành công thì cópayload(luôn là object,{}khi không có gì), thất bại thì cóerror. Portal cũng chủ động gửikind: 'evt'. - Message sai cấu trúc (
wsgkhác1, thiếukindhaytype) bị bỏ qua im lặng ở cả hai phía. payloadđi qua structured clone củapostMessage: chuyển dữ liệu qua JSON trước khi gửi để tránh lỗiDataCloneErrorvới hàm hay object đặc biệt.
Kiểm tra nguồn
- Game chỉ nhận message có
event.source === window.parentvàevent.origin === host. - Portal chỉ nhận message từ đúng
contentWindowcủa iframe game vớievent.originlàhttps://games.leonogames.com, và chỉ gửi tới origin đó. - Không bao giờ dùng
'*'làmtargetOrigin: luôn truyềnhost.
Timeout
| Yêu cầu | Timeout | Khi hết giờ |
|---|---|---|
init | 3 giây | Chạy standalone |
ads.interstitial, ads.rewarded | 120 giây | Coi như { shown: false, reason: 'error' } |
| Còn lại | 10 giây | Lỗi timeout |
Yêu cầu từ game
type | payload | payload trả về | Portal kiểm tra |
|---|---|---|---|
init | { gameId, sdkVersion?, gameVersion? } | InitContext hoặc { disabled: true } | gameId là chuỗi 1–1024 ký tự (dùng slug) |
loading.start | — | {} | |
loading.progress | { percent } | {} | percent từ 0 tới 100 |
loading.finished | — | {} | Gỡ overlay tải |
gameplay.start | — | {} | |
gameplay.stop | — | {} | |
happy | { intensity? } | {} | intensity từ 0 tới 1 |
track | { events: [{ name, props?, ts? }] } | { accepted } | Tối đa 100 sự kiện mỗi yêu cầu; name khớp ^[a-z][a-z0-9_]{1,63}$; ts là epoch ms; sự kiện có props quá 2 KB bị bỏ và không tính vào accepted |
ads.interstitial | { placement? } | AdResult | placement tối đa 64 ký tự |
ads.rewarded | { placement? } | AdResult | Như trên |
lb.submit | { score, meta? } | SubmitResult | score là số; luật chấm điểm ở phía server |
lb.top | { period?, limit? } | { entries } | Mặc định all và 10; limit nguyên 1–100 |
lb.around | { period?, radius? } | { entries } | radius nguyên 0–10, mặc định 5 |
lb.me | { period? } | { entry } | entry có thể là null |
lb.show | { period? } | {} | |
storage.get | { key } | { value } | key 1–128 ký tự; value là null khi chưa có |
storage.set | { entries } | {} | Mỗi yêu cầu là một lần ghi (tối đa 30 lần mỗi phút) |
storage.remove | { key } | {} | |
storage.getAll | — | { data } | |
share | { text?, score?, url? } | { ok } | text tối đa 280 ký tự; url là URL tuyệt đối |
player.token | — | { token } | token là JWT hoặc null |
player.login | — | { player } |
Kiểu InitContext, AdResult, SubmitResult và LeaderboardEntry giống hệt Tham chiếu SDK. Yêu cầu có type lạ hoặc payload sai bị trả lỗi invalid_argument.
Sự kiện từ portal
type | payload | Khi nào |
|---|---|---|
pause | — | Tab bị ẩn (trừ lúc đang hiện quảng cáo); trước khi quảng cáo hiện |
resume | — | Tab hiện lại; sau khi quảng cáo đóng |
audio | { enabled } | false trước quảng cáo, true sau quảng cáo |
visibility | { visible } | Trang portal đổi trạng thái hiển thị |
authchange | { player } | Thông tin người chơi thay đổi |
Client tự cài nên bỏ qua pause khi đang dừng sẵn và resume khi đang chạy, như SDK làm.
Mã lỗi
error.code thuộc tập not_initialized, timeout, quota_exceeded, invalid_argument, rate_limited, unavailable, internal. Portal trả:
invalid_argument:typelạ,payloadsai, hoặc API từ chối dữ liệu;not_initialized: phiên chơi đã kết thúc (vớilb.submit,player.login);quota_exceeded: storage vượt 100 KB;rate_limited: vượt giới hạn tần suất của API;unavailable: portal không gọi được API (mất mạng, bị từ chối quyền), hoặc quản trị portal đã tắt API đó cho game (lb.*khi tắt bảng xếp hạng,storage.*khi tắt lưu dữ liệu; quảng cáo bị tắt thì trả{ shown: false, reason: 'disabled' }, chia sẻ bị tắt thì trả{ ok: false });internal: lỗi khác.
Trình tự chuẩn
game → req init portal ← tạo hoặc dùng lại phiên chơi, res InitContext
game → req loading.start … loading.progress … loading.finished
game → req gameplay.start portal: ghi lượt chơi đầu tiên, cộng XP
game → req track {…}
game → req gameplay.stop (game over)
game → req lb.submit {score} portal → API → res SubmitResult
game → req ads.interstitial portal → evt pause, evt audio(false) → quảng cáo → evt audio(true), evt resume → res AdResult
game → req gameplay.start (lượt mới)Client tối giản
const params = new URLSearchParams(location.search);
const host = params.get('wsg') === '1' ? params.get('host') : null;
const pending = new Map();
const listeners = {};
let seq = 0;
window.addEventListener('message', (event) => {
if (!host || event.source !== window.parent || event.origin !== host) return;
const msg = event.data;
if (!msg || msg.wsg !== 1) return;
if (msg.kind === 'res') {
const p = pending.get(msg.id);
if (!p) return;
pending.delete(msg.id);
clearTimeout(p.timer);
if (msg.error) p.reject(msg.error);
else p.resolve(msg.payload);
} else if (msg.kind === 'evt') {
for (const cb of listeners[msg.type] || []) cb(msg.payload);
}
});
function request(type, payload) {
return new Promise((resolve, reject) => {
const id = `r${++seq}`;
const ms = type === 'init' ? 3000 : type.startsWith('ads.') ? 120000 : 10000;
const timer = setTimeout(() => {
pending.delete(id);
reject({ code: 'timeout', message: type });
}, ms);
pending.set(id, { resolve, reject, timer });
window.parent.postMessage({ wsg: 1, kind: 'req', id, type, payload }, host);
});
}
function on(type, cb) {
(listeners[type] = listeners[type] || []).push(cb);
}
async function connect(gameId) {
if (!host || window.parent === window) return { environment: 'standalone' };
try {
const ctx = await request('init', { gameId });
return ctx.disabled ? { environment: 'disabled' } : ctx;
} catch {
return { environment: 'standalone' }; // portal không trả lời trong 3 giây
}
}
async function main() {
const ctx = await connect('my-game');
if (ctx.environment !== 'portal') return startStandalone(); // tự cài chế độ chạy riêng của bạn
on('pause', () => game.pause());
on('resume', () => game.resume());
await request('loading.finished');
await request('gameplay.start');
// ... hết lượt
await request('gameplay.stop');
const result = await request('lb.submit', { score: 42 });
console.log(result.accepted, result.reason);
}
main();