Có hai đường, chọn theo hoàn cảnh của bạn:
- A. Bạn đã có game HTML5 (canvas thuần, Phaser, PixiJS, Construct, Unity WebGL…): thêm một thẻ script và vài lời gọi hàm. Đây là đường cho studio bên ngoài.
- B. Bạn làm game mới trong repo của WebSuperGames: tạo từ template Phaser có sẵn tích hợp SDK, test hooks và smoke test.
A. Tích hợp vào game HTML5 có sẵn
1. Nhúng SDK
Thêm thẻ script trước script của game, trong index.html ở gốc bundle:
<script src="/sdk/v1/wsg-sdk.js"></script>
<script src="./game.js"></script>File SDK nằm trên chính origin game (https://games.leonogames.com/sdk/v1/wsg-sdk.js), nên bundle tham chiếu nó bằng đường dẫn gốc /sdk/v1/wsg-sdk.js và chạy được ở mọi môi trường mà không phải build lại. Script tạo biến toàn cục window.WSG.
Lưu ý
Đây là ngoại lệ duy nhất của quy tắc "chỉ dùng đường dẫn tương đối". Mọi file khác của game phải tải bằng đường dẫn tương đối như ./game.js, ./assets/bg.png. Khi chạy thử game trên máy, ngoài portal, xem cách phục vụ SDK ở trang Kiểm thử.
2. Khởi tạo
const ctx = await WSG.init({ gameId: 'tap-rush', version: '1.0.0' });
console.log(ctx.environment); // 'portal' | 'standalone' | 'disabled'gameIdlàslugcủa game tronggame.json.- Chờ
init()xong rồi mới gọi API khác. Gọi API bất đồng bộ trước đó sẽ bị từ chối với mãnot_initialized, còn các hàm báo vòng đời nhưgameplayStart()bị bỏ qua. init()không bao giờ thất bại vì môi trường: không có portal, hoặc portal không trả lời trong 3 giây, SDK tự chạy ở chế độstandalone.
3. Báo tiến độ tải
Portal phủ một overlay "Đang tải game" lên iframe và hiện phần trăm bạn báo. Overlay chỉ được gỡ khi bạn gọi loadingFinished() (hoặc sau 30 giây, kèm một sự kiện load_timeout bị ghi lại).
WSG.loadingStart();
WSG.loadingProgress(40); // 0..100
WSG.loadingProgress(100);
WSG.loadingFinished(); // bắt buộc: game sẵn sàng nhận thao tác4. Bắt đầu và kết thúc lượt chơi
function startRound() {
WSG.gameplayStart(); // người chơi bắt đầu điều khiển
}
function endRound() {
WSG.gameplayStop(); // game over, về menu
}Gọi gameplayStart() đúng lúc người chơi thật sự điều khiển, không phải lúc mở menu. Server dùng hai mốc này để đếm lượt chơi, cộng XP cho người chơi và quyết định có nhận điểm hay không.
5. Nộp điểm
WSG.gameplayStop();
const result = await WSG.leaderboard.submit(score); // score là số nguyên ≥ 0
if (result.accepted) {
showBest(result.best, result.rank, result.improved);
} else {
console.warn('Điểm bị từ chối:', result.reason); // ví dụ 'too_fast', 'rate_too_high'
}Nộp điểm khi gameplay đang chạy hoặc trong vòng 10 giây sau gameplayStop(). Điểm bị từ chối vẫn trả về bình thường (không ném lỗi), kèm reason. Ý nghĩa từng lý do ở trang Điểm và bảng xếp hạng.
6. Quảng cáo giữa các lượt
async function playAgain() {
await WSG.ads.interstitial({ placement: 'between_rounds' });
startRound();
}SDK tự phát sự kiện pause trước khi quảng cáo hiện và resume sau khi đóng. Portal giữ khoảng cách tối thiểu 60 giây giữa hai interstitial; gọi sớm hơn sẽ nhận { shown: false, reason: 'cooldown' } và bạn cứ cho chơi tiếp. Chi tiết ở trang Quảng cáo.
7. Tạm dừng khi portal yêu cầu
WSG.on('pause', () => game.pause()); // tab bị ẩn, quảng cáo sắp hiện
WSG.on('resume', () => game.resume());
WSG.on('audio', (enabled) => game.setMuted(!enabled));on() gọi được cả trước init(). Khi nhận pause, dừng vòng lặp game và tắt âm thanh.
Ví dụ đầy đủ
Một game nhỏ "chạm vào vòng tròn trong 15 giây" bằng canvas thuần, dùng đủ các bước trên. Ba file dưới đây là một gói nộp hoàn chỉnh: index.html và game.js nằm trong thư mục build dist/, game.json ở cạnh thư mục đó.
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<link rel="icon" href="data:," />
<title>Tap Rush</title>
<style>
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
background: #171430;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
#stage {
position: fixed;
top: env(safe-area-inset-top, 0px);
right: env(safe-area-inset-right, 0px);
bottom: env(safe-area-inset-bottom, 0px);
left: env(safe-area-inset-left, 0px);
}
canvas {
display: block;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="stage"><canvas id="game"></canvas></div>
<script src="/sdk/v1/wsg-sdk.js"></script>
<script src="./game.js"></script>
</body>
</html>// Tap Rush: chạm vào vòng tròn càng nhiều càng tốt trong 15 giây.
const GAME_ID = 'tap-rush'; // trùng "slug" trong game.json
const ROUND_MS = 15000;
const ASSETS = []; // ví dụ ['./assets/pop.mp3', './assets/bg.png']
const TEXT = {
vi: {
start: 'Chạm để bắt đầu',
again: 'Chạm để chơi lại',
best: 'Kỷ lục',
newBest: 'Kỷ lục mới!',
rank: 'Hạng',
rejected: 'Điểm không được ghi',
},
en: {
start: 'Tap to start',
again: 'Tap to play again',
best: 'Best',
newBest: 'New best!',
rank: 'Rank',
rejected: 'Score not recorded',
},
};
const stage = document.getElementById('stage');
const canvas = document.getElementById('game');
const g = canvas.getContext('2d');
const state = {
phase: 'menu', // 'menu' | 'playing' | 'over'
score: 0,
best: 0,
timeLeft: ROUND_MS,
rounds: 0,
message: '',
busy: false, // đang nộp điểm hoặc đang có quảng cáo
paused: false,
muted: false,
lastTs: 0,
target: { x: 0.5, y: 0.5 },
};
let T = TEXT.en;
let audio = null;
// Sự kiện từ portal (đăng ký trước init cũng được)
WSG.on('pause', () => {
state.paused = true;
});
WSG.on('resume', () => {
state.paused = false;
state.lastTs = 0; // không tính quãng thời gian đã dừng
});
WSG.on('audio', (enabled) => {
state.muted = !enabled;
});
async function preload(urls) {
WSG.loadingStart();
let done = 0;
await Promise.all(
urls.map(async (url) => {
await (await fetch(url)).blob();
done += 1;
WSG.loadingProgress((done / urls.length) * 100);
}),
);
WSG.loadingFinished(); // portal gỡ overlay tải, người chơi bắt đầu chạm được
}
function moveTarget() {
state.target.x = 0.15 + Math.random() * 0.7;
state.target.y = 0.3 + Math.random() * 0.55;
}
function startRound() {
state.phase = 'playing';
state.score = 0;
state.timeLeft = ROUND_MS;
state.message = '';
moveTarget();
WSG.gameplayStart(); // người chơi bắt đầu điều khiển
}
async function endRound() {
state.phase = 'over';
state.busy = true;
WSG.gameplayStop(); // hết lượt; điểm phải nộp trong vòng 10 giây sau đó
try {
const result = await WSG.leaderboard.submit(state.score);
if (result.accepted) {
state.best = result.best;
state.message = result.improved ? T.newBest : `${T.rank} #${result.rank ?? '-'}`;
} else {
state.message = `${T.rejected} (${result.reason})`;
}
} catch (err) {
state.message = `${T.rejected} (${err.code})`;
}
state.rounds += 1;
state.busy = false;
}
async function playAgain() {
state.busy = true;
if (state.rounds % 2 === 0) {
// Mỗi 2 lượt một quảng cáo; SDK tự phát pause/resume quanh quảng cáo.
await WSG.ads.interstitial({ placement: 'between_rounds' });
}
state.busy = false;
startRound();
}
function unlockAudio() {
// iOS chỉ cho phát âm thanh sau một lần chạm của người chơi
if (!audio && window.AudioContext) audio = new AudioContext();
if (audio && audio.state === 'suspended') audio.resume();
}
function beep(freq) {
if (!audio || state.muted || state.paused) return;
const osc = audio.createOscillator();
const gain = audio.createGain();
gain.gain.value = 0.08;
osc.frequency.value = freq;
osc.connect(gain).connect(audio.destination);
osc.start();
osc.stop(audio.currentTime + 0.06);
}
canvas.addEventListener('pointerdown', (event) => {
unlockAudio();
if (state.paused || state.busy) return;
if (state.phase === 'menu') return startRound();
if (state.phase === 'over') return void playAgain();
const rect = canvas.getBoundingClientRect();
const dx = event.clientX - rect.left - state.target.x * rect.width;
const dy = event.clientY - rect.top - state.target.y * rect.height;
if (Math.hypot(dx, dy) <= Math.min(rect.width, rect.height) * 0.12) {
state.score += 1;
moveTarget();
beep(660);
}
});
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(stage.clientWidth * dpr);
canvas.height = Math.round(stage.clientHeight * dpr);
g.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function draw() {
const w = stage.clientWidth;
const h = stage.clientHeight;
g.fillStyle = '#171430';
g.fillRect(0, 0, w, h);
g.fillStyle = '#ffffff';
g.textAlign = 'center';
g.font = 'bold 28px system-ui, sans-serif';
g.fillText(String(state.score), w / 2, 48);
if (state.phase === 'playing') {
g.font = '16px system-ui, sans-serif';
g.fillText(`${Math.ceil(state.timeLeft / 1000)}s`, w / 2, 76);
g.fillStyle = '#ff5a36';
g.beginPath();
g.arc(state.target.x * w, state.target.y * h, Math.min(w, h) * 0.12, 0, Math.PI * 2);
g.fill();
} else {
g.font = 'bold 20px system-ui, sans-serif';
g.fillText(state.phase === 'menu' ? T.start : T.again, w / 2, h / 2);
g.font = '16px system-ui, sans-serif';
g.fillText(state.message || `${T.best}: ${state.best}`, w / 2, h / 2 + 32);
}
}
function frame(ts) {
const dt = state.lastTs ? Math.min(ts - state.lastTs, 100) : 0;
state.lastTs = ts;
if (state.phase === 'playing' && !state.paused) {
state.timeLeft -= dt;
if (state.timeLeft <= 0) {
state.timeLeft = 0;
void endRound();
}
}
draw();
requestAnimationFrame(frame);
}
async function boot() {
const ctx = await WSG.init({ gameId: GAME_ID, version: '1.0.0' });
T = TEXT[ctx.locale] || TEXT.en;
const me = await WSG.leaderboard.me(); // điểm tốt nhất hiện có, null nếu chưa chơi
if (me) state.best = me.score;
resize();
window.addEventListener('resize', resize);
await preload(ASSETS);
requestAnimationFrame(frame);
}
boot();{
"slug": "tap-rush",
"version": "1.0.0",
"title": "Tap Rush",
"description": {
"vi": "Chạm vào vòng tròn càng nhiều càng tốt trong 15 giây.",
"en": "Tap the circle as many times as you can in 15 seconds."
},
"category": "casual",
"tags": ["one-tap", "timing", "tap"],
"orientation": "any",
"controls": ["tap", "mouse"],
"thumbnail": "./thumbnail.png",
"limits": {
"maxScore": 200,
"maxScorePerSecond": 12,
"minDurationMs": 10000,
"scoreOrder": "desc"
},
"build": { "command": "echo prebuilt", "outDir": "dist" },
"author": { "name": "Studio của bạn", "url": "https://example.com" }
}Cách chọn limits cho ví dụ này: tay người chạm nhanh lắm khoảng 8 lần mỗi giây, nên maxScorePerSecond = 8 × 1,5 = 12. Một lượt dài 15 giây nên điểm thật khó vượt 120, đặt maxScore 200 để có biên. Lượt nào cũng kéo dài ít nhất 15 giây, đặt minDurationMs 10000 để chừa độ trễ mạng. Giải thích đầy đủ ở trang game.json.
Chạy thử ví dụ trên máy theo hướng dẫn ở trang Kiểm thử, sau đó xem Nộp và phát hành để gửi gói nộp.
B. Game mới từ template Phaser
Đường này dành cho đội có quyền truy cập repo WebSuperGames. Template packages/game-template là game Phaser 3 + Vite + TypeScript đã nối sẵn SDK qua package nội bộ @wsg/game-kit, có scene Boot → Preload → Menu → Game → GameOver, test hooks và smoke test Playwright.
1. Tạo game
pnpm wsg create my-game # copy template vào games/my-game, đặt slug, title, chạy pnpm install
pnpm sync:sdk # build SDK và copy vào .data/games/sdk/v1
pnpm dev:games # games-server ở http://localhost:4000 (để ở một terminal riêng)
pnpm --filter my-game dev # Vite ở http://localhost:5173Sau khi tạo, sửa games/my-game/game.json: title, description, category, tags, orientation, controls và quan trọng nhất là limits. Kiểm tra bằng pnpm wsg validate games/my-game.
2. Plugin nối SDK
src/main.ts của template khởi tạo SDK rồi gắn WsgPhaserPlugin vào game:
import Phaser from 'phaser';
import { createScaleConfig, resolveWsg, wsgPluginConfig } from '@wsg/game-kit';
import { BACKGROUND_COLOR, GAME_ID, GAME_VERSION, ORIENTATION } from './config';
import { setLocale } from './i18n';
import { Boot } from './scenes/Boot';
import { Game } from './scenes/Game';
import { GameOver } from './scenes/GameOver';
import { Menu } from './scenes/Menu';
import { Preload } from './scenes/Preload';
async function boot(): Promise<Phaser.Game> {
const wsg = await resolveWsg(); // window.WSG từ thẻ script; không tải được thì dùng bản ESM đóng gói
const ctx = await wsg.init({ gameId: GAME_ID, version: GAME_VERSION }); // GAME_ID = slug trong game.json
setLocale(ctx.locale);
return new Phaser.Game({
type: Phaser.AUTO,
parent: 'game',
backgroundColor: BACKGROUND_COLOR,
scale: createScaleConfig(ORIENTATION),
scene: [Boot, Preload, Menu, Game, GameOver],
plugins: { global: [wsgPluginConfig({ wsg })] }, // mọi scene có this.wsg
});
}
boot();Plugin tự làm phần lớn việc tích hợp:
| Việc | Plugin làm thế nào |
|---|---|
loadingStart, loadingProgress | Nghe loader của scene Preload |
loadingFinished | Khi scene Menu được tạo lần đầu |
gameplayStart / gameplayStop | Khi scene Game start, resume, wake / pause, sleep, shutdown |
pause / resume từ portal | game.loop.sleep() / game.loop.wake() và tắt tiếng |
audio(false) | Tắt tiếng; AudioManager lưu lựa chọn tắt tiếng của người chơi vào WSG.storage |
3. Nộp điểm trong scene
// Rút gọn từ template. Scene Game vừa pause nên plugin đã gọi gameplayStop(); nộp điểm ngay.
const result = await this.wsg.submitScore(this.score); // không ném lỗi: lỗi trả về accepted: false
console.log(result.accepted ? `Hạng ${result.rank}` : `Bị từ chối: ${result.reason}`);
const sdk = this.wsg.sdk; // chính là WSG: ads, storage, share, leaderboard
await sdk.storage.set({ best: state.best, runs: state.runs });
if (state.gameOverCount % 2 === 0) {
await sdk.ads.interstitial({ placement: 'between_rounds' });
}Khi game tự tạm dừng (người chơi bấm nút dừng) mà không dùng scene.pause(), gọi tay this.wsg.gameplayStop() và this.wsg.gameplayStart().
4. Kiểm thử và phát hành
pnpm --filter my-game test:e2e # build, deploy lên games-server local, smoke test Playwright
WSG_SDK_URL=/sdk/v1/wsg-sdk.js pnpm wsg publish games/my-gamepnpm wsg publish chạy validate → build → upload → đăng ký với API. Đặt WSG_SDK_URL=/sdk/v1/wsg-sdk.js khi build bản phát hành để bundle tham chiếu SDK bằng đường dẫn gốc; khi chạy Vite ở máy, template dùng mặc định http://localhost:4000/sdk/v1/wsg-sdk.js. Vite sẽ in cảnh báo can't be bundled without type="module" attribute cho thẻ script này; đó là điều mong muốn, vì SDK phải được tải riêng từ origin game chứ không gộp vào bundle. Xem thêm Engine và framework và Nộp và phát hành.