SDK & Libraries
You can call the edu.games LRS directly over HTTP, or use one of these libraries to simplify xAPI integration in your game. Whichever you pick, a game that runs inside the edu.games player must take its endpoint, auth and actor from the launch context — never from a key and secret baked into the build.
Bearer launch token instead, and the launch-context actor is the only identity the LRS will store. See Authentication.The first-party SDK. It discovers the launch context the player injects (endpoint, per-session auth, actor, registration) — your game never handles credentials — and gives you typed statement builders that follow the emission profile exactly: batching, flush-on-close, retry and dedupe ids are built in. It is served from the CDN as a single script that defines a global EduGames; it is not published to npm.
<!-- One tag in your game's index.html, before your own scripts -->
<script src="https://edu.games/sdk/edugames-sdk.v1.js"></script>// window.EduGames is defined by the script tag above
const client = await EduGames.init({ game: 'your-game-slug' });
// null only when no launch context could be discovered at all
// (e.g. opening index.html from file:// with no player around).
if (!client) { /* run without telemetry */ }
client.launched(); // once per load (Core)
client.attempted('fractions-1'); // attempt starts (Core)
client.passed('fractions-1', { // objective outcome
score: 0.85, // proportion of attainable, clamped to -1..1
durationSeconds: 47, // Timed level (active seconds)
confidence: 0.6, // Confidence level (0..1, pre-reveal)
});
client.failed('fractions-2', { score: 0.2 });
client.retried('fractions-2'); // alongside the new attempted()
client.askedForHint('fractions-2');
client.mastered('fractions-2');
client.chose('crossroads', 'left', { sequenceIndex: 4 }); // Choice level
client.progressed('fractions-2', 'in_progress'); // Full level: objective state
client.experienced('tutorial', 'Tutorial screen'); // genuine content exposure
client.logged('powerup-collected'); // telemetry side channel
client.completed({ score: 0.9, durationSeconds: 310 });
// Abandonment is automatic: closing mid-session emits terminated for you.| API | Behaviour |
|---|---|
| EduGames.init({ game, launch?, launchTimeoutMs? }) | Async. Discovers the launch context in order: window.EDUGAMES_LAUNCH → window.XAPI_ENDPOINT + XAPI_AUTH → a single { type: 'edugames:launch' } postMessage from window.parent (endpoint must be https on edu.games, *.edu.games or edugames.dev, else the whole message is dropped) → after launchTimeoutMs (default 3000) resolves null. Pass launch: { endpoint, auth, actor?, registration? } to skip discovery (server-side tests). Missing actor → anon:<uuid> account; missing registration → fresh UUID. |
| launched() | Exactly once per load; later calls are ignored. |
| attempted / passed / failed / retried / askedForHint / mastered (objective) | Objective statements. A bare slug becomes https://edu.games/games/<game>/objectives/<slug>; a full IRI passes through. passed sets result.success true, failed sets it false. |
| ResultOpts { score?, durationSeconds?, confidence? } | score → result.score.scaled clamped to -1..1; durationSeconds → ISO 8601 PT…S rounded to 0.1 s; confidence → result.extensions[…/ext/confidence]. |
| chose(point, choice, { sequenceIndex?, parentPoint? }) | One statement per branch point, object …/decisions/<point>/<choice>, with the decision-point / sequence-index / parent-point context extensions. |
| progressed(objective, state) | experienced verb on the objective with the state extension: not_started | in_progress | completed | mastered. |
| experienced(contentSlug, name?) | Content exposure at …/content/<slug>, optional display name. |
| logged(eventSlug) | Non-learning telemetry at …/events/<slug>. |
| completed(opts?) / terminated() | Session end. terminated triggers an immediate keepalive flush; a pagehide after launched() with neither sent emits terminated automatically. |
| send(rawStatement) | Escape hatch: enqueue any statement — id, actor, registration and the profile-version category are stamped if missing. |
| objective(slug) | Returns the IRI the builders would use, for your own state or logging. |
| flush() | Force the queue to send now. Otherwise batches flush every 5 s, whenever 20 statements are queued, and on pagehide / visibilitychange: hidden with keepalive. 429 and 5xx responses are retried with the same ids (the LRS dedupes); other 4xx batches are dropped. |
Every sample below starts from this helper instead of a hardcoded credential. It follows the same discovery order and security rules the SDK implements: injected globals first (only the real player can set those), then one edugames:launch message from window.parent whose endpoint is on the allowlist, and nothing else. If neither turns up, run without telemetry or in anonymous mode per the profile.
// launch.js — resolve the edu.games launch context (no credentials in your build)
function endpointAllowed(endpoint) {
try {
const u = new URL(endpoint);
const h = u.hostname;
return u.protocol === 'https:' &&
(h === 'edu.games' || h.endsWith('.edu.games') || h === 'edugames.dev' || h.endsWith('.edugames.dev'));
} catch { return false; }
}
export function discoverLaunch(timeoutMs = 3000) {
const g = window.EDUGAMES_LAUNCH;
if (g?.endpoint && g.auth) {
return Promise.resolve({ endpoint: g.endpoint, auth: g.auth, actor: g.actor ?? null, registration: g.registration ?? null });
}
if (window.XAPI_ENDPOINT && window.XAPI_AUTH) {
return Promise.resolve({ endpoint: window.XAPI_ENDPOINT, auth: window.XAPI_AUTH, actor: null, registration: null });
}
return new Promise(resolve => {
let done = false;
const finish = ctx => { if (done) return; done = true; window.removeEventListener('message', onMessage); resolve(ctx); };
const onMessage = e => {
if (e.source !== window.parent) return; // parent-source check
const d = e.data;
if (d?.type !== 'edugames:launch') return;
if (!d.endpoint || !d.auth || !endpointAllowed(d.endpoint)) return; // reject the whole message
finish({ endpoint: d.endpoint, auth: d.auth, actor: d.actor ?? null, registration: d.registration ?? null });
};
window.addEventListener('message', onMessage);
setTimeout(() => finish(null), timeoutMs); // first launch wins; later messages ignored
});
}
// Anonymous fallback identity when the launch carries none
export function completeLaunch(ctx) {
return {
...ctx,
actor: ctx.actor ?? { objectType: 'Agent', account: { homePage: 'https://edu.games', name: 'anon:' + crypto.randomUUID() } },
registration: ctx.registration ?? crypto.randomUUID(),
};
}For simple games, a small wrapper around fetch is all you need. The auth value from the launch context is a complete Authorization header value (Bearer … inside the player) — send it verbatim.
// lrs.js — drop this file into your game
import { discoverLaunch, completeLaunch } from './launch.js';
let launch = null;
export async function initLrs() {
const ctx = await discoverLaunch();
launch = ctx ? completeLaunch(ctx) : null; // null → no telemetry this session
return launch;
}
const PROFILE = { id: 'https://edu.games/xapi/profiles/emission/v1',
definition: { type: 'http://adlnet.gov/expapi/activities/profile' } };
function headers() {
return {
'Authorization': launch.auth, // echoed verbatim from the launch context
'Content-Type': 'application/json',
'X-Experience-API-Version': '1.0.3',
};
}
export async function sendStatement(statement) {
if (!launch) return;
const res = await fetch(`${launch.endpoint.replace(/\/$/, '')}/statements`, {
method: 'POST',
headers: headers(),
body: JSON.stringify({
id: crypto.randomUUID(),
actor: launch.actor, // the platform actor, never your own
context: { registration: launch.registration, contextActivities: { category: [PROFILE] } },
timestamp: new Date().toISOString(),
...statement,
}),
});
if (!res.ok && res.status !== 204) throw new Error(`LRS ${res.status}`);
}
export async function saveState(activityId, stateId, data) {
if (!launch) return;
const params = new URLSearchParams({ activityId, agent: JSON.stringify(launch.actor), stateId });
await fetch(`${launch.endpoint}/activities/state?${params}`, {
method: 'PUT', headers: headers(), body: JSON.stringify(data),
});
}
export async function loadState(activityId, stateId) {
if (!launch) return null;
const params = new URLSearchParams({ activityId, agent: JSON.stringify(launch.actor), stateId });
const res = await fetch(`${launch.endpoint}/activities/state?${params}`, { headers: headers() });
return res.status === 200 ? res.json() : null;
}The official ADL xAPI wrapper. It expects a username/password pair, so hand it the launch context’s ready-made auth header instead of a key and secret.
npm install xapiwrapperimport { XAPIWrapper } from 'xapiwrapper';
import { discoverLaunch, completeLaunch } from './launch.js';
const ctx = await discoverLaunch();
if (!ctx) { /* no player: skip telemetry */ }
const launch = completeLaunch(ctx);
const lrs = new XAPIWrapper({
endpoint: launch.endpoint.replace(/\/?$/, '/'), // e.g. https://lrs.edu.games/xapi/
auth: launch.auth, // full Authorization header value from the launch
});
// Send a statement — actor and registration come from the launch context
lrs.sendStatement({
actor: launch.actor,
verb: { id: 'http://adlnet.gov/expapi/verbs/completed', display: { 'en-US': 'completed' } },
object: { id: 'https://edu.games/games/your-game/objectives/level-1', objectType: 'Activity' },
result: { score: { scaled: 0.85 }, success: true, completion: true },
context: { registration: launch.registration },
}, (response) => {
console.log('Statement ID:', response.responseText);
});
// Save state
lrs.sendState(
{ id: 'https://edu.games/games/your-game', objectType: 'Activity' },
launch.actor,
null, 'progress',
{ level: 5, score: 1240 }
);
// Load state
lrs.getState(
'https://edu.games/games/your-game',
launch.actor,
'progress', null, null,
(response) => {
const progress = JSON.parse(response.responseText);
console.log('Current level:', progress.level);
}
);Mature Tin Can / xAPI library from Rustici Software. Well-suited to Phaser and other game frameworks. Its RemoteLRS accepts a pre-built auth header value, which is exactly what the launch context provides.
npm install tincanjsimport { discoverLaunch, completeLaunch } from './launch.js';
const ctx = await discoverLaunch();
const launch = completeLaunch(ctx); // guard for null ctx in real code
const lrs = new TinCan.RemoteLRS({
endpoint: launch.endpoint.replace(/\/?$/, '/'),
auth: launch.auth, // no username/password — the launch token is the auth
});
lrs.saveStatement(
new TinCan.Statement({
actor: new TinCan.Agent(launch.actor), // platform actor, verbatim
verb: new TinCan.Verb({
id: 'http://adlnet.gov/expapi/verbs/completed',
display: { 'en-US': 'completed' },
}),
target: new TinCan.Activity({
id: 'https://edu.games/games/your-game/objectives/level-1',
definition: { name: { 'en-US': 'Level 1' } },
}),
result: new TinCan.Result({
score: new TinCan.Score({ scaled: 0.85 }),
completion: true,
success: true,
}),
context: new TinCan.Context({ registration: launch.registration }),
}),
(err, result) => {
if (err) console.error('LRS error:', err);
else console.log('Saved:', result.id);
}
);Hook xAPI calls into Phaser’s scene lifecycle events for automatic statement tracking. Resolve the launch context once at boot; the identity travels with it, so scenes never build their own actor.
// boot.js — resolve the launch before the game starts
import { initLrs } from './lrs.js';
await initLrs(); // null result = play without telemetry
new Phaser.Game(config);
// LevelScene.js
import { sendStatement, saveState, loadState } from './lrs.js';
export const metadata = {
title: 'SDK & Libraries',
description: 'The official edu.games browser SDK and other xAPI client libraries.',
}
const GAME_ID = 'https://edu.games/games/your-game';
const LEVEL_ID = (n) => `${GAME_ID}/objectives/level-${n}`;
class LevelScene extends Phaser.Scene {
constructor() { super('LevelScene'); }
async create() {
this.startTime = Date.now();
// Load saved progress (keyed to the launch actor inside lrs.js)
this.progress = await loadState(GAME_ID, 'progress') ?? { level: 1, score: 0 };
// Track that the player started this level
await sendStatement({
verb: { id: 'http://adlnet.gov/expapi/verbs/attempted', display: { 'en-US': 'attempted' } },
object: { objectType: 'Activity', id: LEVEL_ID(this.progress.level),
definition: { type: 'http://adlnet.gov/expapi/activities/objective' } },
});
}
async onLevelComplete(score, maxScore) {
const elapsed = Math.round((Date.now() - this.startTime) / 1000);
const passed = score / maxScore >= 0.7;
await sendStatement({
verb: { id: passed ? 'http://adlnet.gov/expapi/verbs/passed' : 'http://adlnet.gov/expapi/verbs/failed' },
object: { objectType: 'Activity', id: LEVEL_ID(this.progress.level),
definition: { type: 'http://adlnet.gov/expapi/activities/objective' } },
result: {
score: { scaled: score / maxScore },
success: passed,
duration: `PT${elapsed}S`,
},
});
this.progress.level++;
this.progress.score += score;
await saveState(GAME_ID, 'progress', this.progress);
}
}Before publishing, open your game in the developer sandbox from your dev-portal dashboard: it runs your uploaded package on edugames.dev as a test student, with a live conformance-checked statement feed beside the game. Sandbox statements never reach real analytics.