Documentation
RESOURCES

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.

Key/secret Basic auth is for server-side and tooling use only. Your xAPI key and secret (from Plugins & SDKs in the dev portal) are long-lived credentials for scripts, back-end services and local testing. A game shipped to browsers must never embed them: the player hands every session a short-lived Bearer launch token instead, and the launch-context actor is the only identity the LRS will store. See Authentication.
edu.games SDK (official)
Recommended

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.

html
<!-- One tag in your game's index.html, before your own scripts -->
<script src="https://edu.games/sdk/edugames-sdk.v1.js"></script>
javascript
// 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.
APIBehaviour
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.
Reading the launch context yourself

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.

javascript
// 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(),
  };
}
Vanilla fetch (no dependencies)

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.

javascript
// 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.

bash
npm install xapiwrapper
javascript
import { 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.

bash
npm install tincanjs
javascript
import { 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);
  }
);
Phaser 3 Integration

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.

javascript
// 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);
  }
}
Test on edugames.dev

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.

Rate LimitsData Retention & Privacy