Bỏ qua tới nội dung
Các trang tài liệu

Nhà phát triển · Nâng cao và hỏi đáp

Giao thức bridge

Giao thức postMessage cấp thấp giữa game và portal, cho trường hợp không dùng được file SDK — envelope, các yêu cầu, sự kiện, kiểm tra origin, timeout và mã lỗi.

Trên trang này

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:

  1. Đang ở trong iframe: window.parent !== window.
  2. Query wsg bằng 1.
  3. Query host là một origin http hoặc https hợ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

TypeScript
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ột id riêng (ví dụ r1, r2), gửi bằng window.parent.postMessage(msg, host).
  • Portal → game: kind: 'res' cùng id và type vớ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ửi kind: 'evt'.
  • Message sai cấu trúc (wsg khác 1, thiếu kind hay type) bị bỏ qua im lặng ở cả hai phía.
  • payload đi qua structured clone của postMessage: chuyển dữ liệu qua JSON trước khi gửi để tránh lỗi DataCloneError với hàm hay object đặc biệt.

Kiểm tra nguồn

  • Game chỉ nhận message có event.source === window.parent và event.origin === host.
  • Portal chỉ nhận message từ đúng contentWindow của iframe game với event.origin là https://games.leonogames.com, và chỉ gửi tới origin đó.
  • Không bao giờ dùng '*' làm targetOrigin: luôn truyền host.

Timeout

Yêu cầuTimeoutKhi hết giờ
init3 giâyChạy standalone
ads.interstitial, ads.rewarded120 giâyCoi như { shown: false, reason: 'error' }
Còn lại10 giâyLỗi timeout

Yêu cầu từ game

typepayloadpayload 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? }AdResultplacement tối đa 64 ký tự
ads.rewarded{ placement? }AdResultNhư trên
lb.submit{ score, meta? }SubmitResultscore 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

typepayloadKhi 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: type lạ, payload sai, hoặc API từ chối dữ liệu;
  • not_initialized: phiên chơi đã kết thúc (với lb.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

Một lượt chơi
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

JavaScriptbridge-client.js
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();