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

Nhà phát triển · Hướng dẫn

Điểm và bảng xếp hạng

Luồng nộp điểm, các kỳ bảng xếp hạng, XP của người chơi, luật chống gian lận theo thứ tự, mẹo chọn limits và xác thực người chơi bằng JWT.

Trên trang này

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

Trình tự
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? }
JavaScript
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
dailyNgày hiện tại, dạng YYYY-MM-DD00:00 giờ Việt Nam mỗi ngày
weeklyTuần ISO hiện tại, bắt đầu thứ Hai, dạng YYYY-Www00:00 thứ Hai giờ Việt Nam
allMọi lúcKhô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" trong game.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ằng show().

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ệnXP
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 trareason
1Phiên chơi tồn tại, chưa kết thúc và thuộc về người chơi đang nộpinvalid_session
—Bảng xếp hạng của game đang được bật trên portaldisabled
—Phiên chơi đã bị đánh dấu nghi vấnblocked
2Điểm là số nguyên, từ 0 tới 2^53invalid_score
3Tố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ơirate_limited
4Gameplay đang chạy, hoặc gameplayStop() cách đây không quá 10 giâyno_gameplay
5Điểm ≤ limits.maxScoreover_max
6Thời gian chơi ≥ limits.minDurationMstoo_fast
7Điểm ÷ số giây chơi ≤ limits.maxScorePerSecondrate_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 được gameplayStart() 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_rejected kèm reason và đ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ận blocked, 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

reasonNguyên nhân hay gặpCách sửa
invalid_sessionPhiê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ũ đã đóngNộ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ớnMath.floor() và kẹp điểm trước khi nộp
rate_limitedNộp nhiều lần trong một giây, hoặc quá 30 lần mỗi phútChỉ nộp một lần mỗi lượt
no_gameplayChư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ềuGọ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 maxScoreKẹp điểm trong game, hoặc nâng maxScore nếu thiết kế cho phép
too_fastLượ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ừngXem mục dưới và cách chọn limits
blockedPhiên đã bị từ chối 5 lần trong 10 phútSử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 adminKhô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:

  1. 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.
  2. 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ại gameplayStart() khi chơi tiếp, mốc thời gian bị đặt lại.
  3. 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ơn minDurationMs thì 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 maxScorePerSecond nế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.
  • minDurationMs dướ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.
  • limits nằm trong game.json củ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:

Bash
# 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.

  1. Trong game, lấy token bằng WSG.player.getToken(). Ở standalone token là null.
  2. Gửi token lên server trong header Authorization: Bearer <token>.
  3. 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á theo kid trong header), rồi kiểm tra các claim.
ClaimÝ nghĩa
issOrigin công khai của API phát hành token
subId người chơi (UUID), trùng WSG.player.id trong portal
gidId của game (UUID), không phải slug
sidId phiên chơi (UUID)
guesttrue nếu là tài khoản khách
iatThời điểm phát hành (giây Unix)
expThờ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 gid bằng id game của bạn, để không nhận token cấp cho game khác. Lấy id bằng curl https://api.leonogames.com/api/v1/games/<slug> (trường id) sau khi game được phát hành.
  • iss là 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ác https://api.leonogames.com; đọc iss từ một token thật hoặc hỏi đội vận hành trước khi bật kiểm tra issuer.
  • 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.
JavaScriptverify-token.mjs
// 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 };
}
JavaScriptserver.mjs
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);
JavaScriptTrong game
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());
}