Mỗi game có bảng xếp hạng riêng theo ba kỳ: hôm nay, tuần này và mọi lúc. Người chơi thấy bảng ngay dưới khung game trên portal, kèm vị trí của chính họ. Điểm chỉ lên bảng khi vượt qua các kiểm tra chống gian lận của server.
Luồng nộp điểm
game gameplayStart() portal gửi ngay gameplay_start lên server
game ... người chơi chơi ...
game gameplayStop() portal gửi ngay gameplay_stop
game leaderboard.submit(42) (trong vòng 10 giây sau gameplayStop)
portal gửi nốt sự kiện đang chờ, rồi nộp điểm cho server
server kiểm tra theo thứ tự 7 luật → SubmitResult
game nhận { accepted, best, rank, improved, reason? }async function onGameOver(score) {
WSG.gameplayStop();
const result = await WSG.leaderboard.submit(Math.floor(score));
if (result.accepted) {
showResult({ best: result.best, rank: result.rank, newRecord: result.improved });
} else {
showResult({ best: result.best, rank: result.rank, note: result.reason });
}
}Điểm bị từ chối không làm Promise reject. best và rank luôn có (khi bị từ chối là trạng thái hiện tại của người chơi), nên màn game over hiện được trong mọi trường hợp.
Các kỳ bảng xếp hạng
| Kỳ | Phạm vi | Đặt lại |
|---|---|---|
daily | Ngày hiện tại, dạng YYYY-MM-DD | 00:00 giờ Việt Nam mỗi ngày |
weekly | Tuần ISO hiện tại, bắt đầu thứ Hai, dạng YYYY-Www | 00:00 thứ Hai giờ Việt Nam |
all | Mọi lúc | Không đặt lại |
- Múi giờ là
Asia/Ho_Chi_Minh(UTC+7) cho mọi người chơi. - Mỗi người chơi chỉ có một dòng trong mỗi kỳ: điểm tốt nhất của họ trong kỳ đó.
- Một lần nộp được nhận cập nhật cả ba kỳ, ở kỳ nào điểm mới tốt hơn điểm cũ.
scoreOrder: "asc"tronggame.jsonđảo chiều so sánh: điểm thấp hơn là tốt hơn.- Trong game, đọc bảng bằng
top(),around(),me(), hoặc mở bảng của portal bằngshow().
XP của người chơi
Ngoài bảng theo game, portal có bảng người chơi theo XP tại https://leonogames.com/leaderboard. XP do server tính từ các tín hiệu game gửi, cộng vào cả ba kỳ ngày, tuần, mọi lúc:
| Sự kiện | XP |
|---|---|
gameplayStart() đầu tiên của một phiên chơi | +10 |
| Mỗi 10 giây gameplay (tính khi gameplay dừng hoặc phiên kết thúc), tối đa 60 XP mỗi phiên | +1 |
| Điểm được nhận phá kỷ lục mọi lúc của người chơi ở game đó | +20 |
| Sau khi nộp, hạng mọi lúc ≤ 100 / ≤ 10 / = 1 | +20 / +50 / +100 |
Thưởng theo hạng lấy mốc cao nhất đạt được ở lần nộp đó, và mỗi mốc chỉ được cộng một lần cho mỗi người chơi ở mỗi game. Vì vậy hãy gọi gameplayStart() và gameplayStop() đúng lúc: gọi sai làm người chơi mất XP hoặc nhận XP không công bằng.
Luật chống gian lận
Server kiểm tra lần lượt và dừng ở luật đầu tiên không đạt, trả HTTP 200 với { accepted: false, reason } (không tiết lộ ngưỡng):
| # | Kiểm tra | reason |
|---|---|---|
| 1 | Phiên chơi tồn tại, chưa kết thúc và thuộc về người chơi đang nộp | invalid_session |
| — | Bảng xếp hạng của game đang được bật trên portal | disabled |
| — | Phiên chơi đã bị đánh dấu nghi vấn | blocked |
| 2 | Điểm là số nguyên, từ 0 tới 2^53 | invalid_score |
| 3 | Tối đa 1 lần nộp mỗi giây cho mỗi phiên và 30 lần mỗi phút cho mỗi người chơi | rate_limited |
| 4 | Gameplay đang chạy, hoặc gameplayStop() cách đây không quá 10 giây | no_gameplay |
| 5 | Điểm ≤ limits.maxScore | over_max |
| 6 | Thời gian chơi ≥ limits.minDurationMs | too_fast |
| 7 | Điểm ÷ số giây chơi ≤ limits.maxScorePerSecond | rate_too_high |
- Thời gian chơi (elapsed) là khoảng từ lần
gameplayStart()gần nhất tới lúc server nhận điểm, tối thiểu 1 giây. Mốc bắt đầu là lúc trang portal nhận đượcgameplayStart()của game; mốc này được dùng nếu tới server trong vòng 15 giây, nếu không server dùng giờ nhận của chính nó. - Mỗi lần bị từ chối được ghi lại (sự kiện
score_rejectedkèmreasonvà điểm) và tính vào bộ đếm của người chơi. Đủ 5 lần trong cùng một khung 10 phút, phiên chơi hiện tại bị đánh dấu nghi vấn và mọi lần nộp sau trong phiên đó nhậnblocked, cho tới khi người chơi mở một phiên mới (bấm Chơi lại trên portal hoặc tải lại trang). - Một lần nộp bị từ chối ở luật 4–7 vẫn tính vào giới hạn của luật 3.
- Điểm được nhận được lưu lại, cập nhật ba kỳ và cộng XP.
Lý do từ chối và cách sửa
reason | Nguyên nhân hay gặp | Cách sửa |
|---|---|---|
invalid_session | Phiên đã kết thúc: người chơi rời trang hay tải lại, hoặc game còn chạy khi phiên cũ đã đóng | Nộp ngay khi hết lượt; game không cần làm gì thêm |
invalid_score | Điểm có phần thập phân, âm, hoặc quá lớn | Math.floor() và kẹp điểm trước khi nộp |
rate_limited | Nộp nhiều lần trong một giây, hoặc quá 30 lần mỗi phút | Chỉ nộp một lần mỗi lượt |
no_gameplay | Chưa gọi gameplayStart(); gọi trước khi init() xong; nộp quá 10 giây sau gameplayStop(); sự kiện gameplay_start bị bỏ vì game track quá nhiều | Gọi gameplayStart() sau await WSG.init(), nộp ngay sau gameplayStop(), giảm số sự kiện track |
over_max | Điểm lớn hơn maxScore | Kẹp điểm trong game, hoặc nâng maxScore nếu thiết kế cho phép |
too_fast | Lượt kết thúc nhanh hơn minDurationMs, hoặc gameplayStart() vừa được gọi lại ngay trước khi nộp | Đặt minDurationMs bằng thời gian nhanh nhất có thể; xem mục dưới |
rate_too_high | Điểm chia thời gian chơi vượt maxScorePerSecond, hay gặp sau khi hồi sinh hoặc tạm dừng | Xem mục dưới và cách chọn limits |
blocked | Phiên đã bị từ chối 5 lần trong 10 phút | Sửa nguyên nhân gốc; người chơi cần mở phiên mới |
disabled | Đội vận hành đã tắt bảng xếp hạng cho game này trong trang admin | Không cần sửa game; kiểm tra WSG.isAvailable('leaderboard') để ẩn nút BXH. Lần từ chối này không tính vào ngưỡng blocked |
Thời gian chơi khi tạm dừng và hồi sinh
Vì thời gian chơi được tính từ lần gameplayStart() gần nhất, mỗi lần gameplay dừng rồi chạy lại giữa một lượt sẽ đặt lại mốc tính. Ví dụ với maxScorePerSecond: 3:
- Người chơi đạt 40 điểm sau 30 giây rồi chết. Game nộp 40: 40 ÷ 30 = 1,3 điểm/giây, được nhận.
- Người chơi xem quảng cáo rewarded để hồi sinh. SDK đã tự gọi
gameplayStop()trước quảng cáo; game gọi lạigameplayStart()khi chơi tiếp, mốc thời gian bị đặt lại. - Bốn giây sau người chơi chết với 42 điểm. Game nộp 42: 42 ÷ 4 = 10,5 điểm/giây, bị từ chối
rate_too_high. Nếu người chơi chết sớm hơnminDurationMsthì làtoo_fast.
Tình huống tương tự xảy ra khi bạn gọi gameplayStop() và gameplayStart() lúc người chơi tạm dừng. Cách giảm rủi ro:
- Nộp điểm ở mỗi lần chết, trước khi mời hồi sinh. Lần nộp đầu tính trên cả lượt nên được nhận; nếu lần nộp sau khi hồi sinh bị từ chối, kỷ lục đã ghi vẫn còn, chỉ phần điểm thêm là chưa được tính. Template Phaser làm đúng như vậy.
- Giới hạn hồi sinh một lần mỗi lượt.
- Chừa biên rộng hơn cho
maxScorePerSecondnếu game cho hồi sinh hoặc tạm dừng giữa lượt. - Nhớ rằng mỗi lần bị từ chối đều tính vào ngưỡng
blocked(5 lần trong 10 phút).
Mẹo chọn limits
- Tính tốc độ ghi điểm tối đa từ thiết kế rồi nhân 1,5; đừng ước lượng theo tay người.
- Kẹp điểm trong game ở
maxScoređể không bao giờ nộp vượt. minDurationMsdưới 1000 không có tác dụng vì thời gian chơi tối thiểu luôn là 1 giây.- Thử những lượt chơi hợp lệ nhanh nhất và điểm cao nhất trên portal thử nghiệm trước khi phát hành.
limitsnằm tronggame.jsoncủa từng phiên bản. Muốn đổi, phát hành phiên bản mới; phiên chơi tạo sau khi phát hành sẽ dùng giá trị mới.- Cẩn thận với
scoreOrder: "asc": xem cảnh báo ở trang game.json.
Đọc bảng xếp hạng từ server của bạn
API đọc bảng xếp hạng là công khai, dùng được từ website hay server của bạn:
# Lấy id (UUID) của game từ slug
curl -s https://api.leonogames.com/api/v1/games/tap-rush
# Top 10 tuần này của game
curl -s "https://api.leonogames.com/api/v1/leaderboards/games/<id-của-game>?period=weekly&limit=10"Kết quả có dạng { period, bucket, entries, me }, trong đó entries là các dòng { rank, playerId, name, avatarUrl, score, isMe } và me là null khi gọi không có cookie người chơi. period mặc định all, limit từ 1 tới 100 (mặc định 50). Các API đọc công khai chung giới hạn 300 request mỗi phút cho mỗi IP, nên hãy cache kết quả. Chỉ game đã phát hành mới có trong API.
Xác thực người chơi phía server game
Khi game nói chuyện với server riêng của bạn (sau khi đã thoả thuận với đội vận hành, xem Yêu cầu với game), dùng token portal cấp để biết chắc người chơi là ai.
- Trong game, lấy token bằng
WSG.player.getToken(). Ở standalone token lànull. - Gửi token lên server trong header
Authorization: Bearer <token>. - Server kiểm chứng chữ ký RS256 bằng khoá công khai ở
https://api.leonogames.com/.well-known/jwks.json(JWKS, cache 1 giờ, chọn khoá theokidtrong header), rồi kiểm tra các claim.
| Claim | Ý nghĩa |
|---|---|
iss | Origin công khai của API phát hành token |
sub | Id người chơi (UUID), trùng WSG.player.id trong portal |
gid | Id của game (UUID), không phải slug |
sid | Id phiên chơi (UUID) |
guest | true nếu là tài khoản khách |
iat | Thời điểm phát hành (giây Unix) |
exp | Thời điểm hết hạn = iat + 3600 |
Header của token có alg: "RS256", typ: "JWT" và kid. Token không có claim aud.
- Luôn kiểm tra
gidbằng id game của bạn, để không nhận token cấp cho game khác. Lấy id bằngcurl https://api.leonogames.com/api/v1/games/<slug>(trườngid) sau khi game được phát hành. isslà origin công khai của API. Khi portal phục vụ API qua chính origin của trang web (như môi trường thử nghiệm hiện tại), giá trị này có thể kháchttps://api.leonogames.com; đọcisstừ một token thật hoặc hỏi đội vận hành trước khi bật kiểm traissuer.- Token sống 1 giờ kể từ lúc phiên chơi bắt đầu và không được cấp lại trong cùng phiên. Khi server báo hết hạn, mời người chơi bấm Chơi lại để có phiên và token mới.
// Node 20+, npm install jose
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://api.leonogames.com/.well-known/jwks.json'));
const GAME_ID = process.env.WSG_GAME_ID; // id (UUID) của game trên portal
export async function verifyPlayer(token) {
// Kiểm tra chữ ký RS256, exp và iat; ném lỗi nếu token không hợp lệ hoặc đã hết hạn
const { payload } = await jwtVerify(token, JWKS, { algorithms: ['RS256'] });
if (payload.gid !== GAME_ID) throw new Error('Token không thuộc game này');
return { playerId: payload.sub, sessionId: payload.sid, guest: payload.guest === true };
}import { createServer } from 'node:http';
import { verifyPlayer } from './verify-token.mjs';
createServer(async (req, res) => {
// Game chạy ở origin game, nên server phải cho phép CORS từ origin đó
res.setHeader('Access-Control-Allow-Origin', 'https://games.leonogames.com');
res.setHeader('Access-Control-Allow-Headers', 'authorization, content-type');
if (req.method === 'OPTIONS') return res.writeHead(204).end();
try {
const token = (req.headers.authorization ?? '').replace(/^Bearer /, '');
const player = await verifyPlayer(token);
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ playerId: player.playerId, guest: player.guest }));
} catch {
res.writeHead(401).end();
}
}).listen(8080);const token = await WSG.player.getToken();
if (token) {
const res = await fetch('https://api.studio-cua-ban.example/me', {
headers: { Authorization: `Bearer ${token}` },
});
console.log(await res.json());
}