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-play (after attempted(), before
// completed()) emits terminated for you. A title-screen bounce emits nothing.| 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() | Play end. A play opens at its first attempted() and closes at completed() or terminated(); a later attempted() ("Play Again") opens a new one. terminated triggers an immediate keepalive flush. A pagehide while a play is open emits terminated automatically; a load that never called attempted() (a title-screen bounce) emits nothing after launched. |
| 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 (up to 20 statements and 30 KB, sent even while another request is in flight). 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.