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-play (after attempted(), before
// completed()) emits terminated for you. A title-screen bounce emits nothing.
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()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.
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 →