Skip to content

❓ RevLearning FAQ

This FAQ answers common integration, configuration, and LMS behaviour questions.

It is intended to help developers understand how RevLearning is designed to be used, what behaviour is expected, and where responsibility belongs between the course, RevLearning, and the LMS.


General Questions

What is RevLearning?

RevLearning is a lightweight learning-runtime abstraction layer for Unity.

It provides a consistent API for communicating with learning standards such as:

  • SCORM 1.2
  • SCORM 2004
  • xAPI

Rather than writing LMS-specific code throughout your project, your content communicates with RevLearning and RevLearning communicates with the LMS.


Does RevLearning require SCORM?

No.

RevLearning is designed around runtime abstractions.

While SCORM 1.2 and SCORM 2004 are currently supported, the goal is for learning code to depend on:

ILmsRuntime
IScormDataModel

rather than a specific learning standard.


Does RevLearning require a specific LMS?

No.

RevLearning is LMS-agnostic.

Any LMS capable of hosting supported learning standards should work. Platforms customers commonly ask about — SCORM Cloud, Thrive LXP, Moodle, LearnUpon, Cornerstone, SuccessFactors — all host SCORM packages and are expected to.

"Expected to" is doing real work in that sentence. Which platforms have actually been driven through the runtime checklist, and on which build, is recorded in Tests~/CONFORMANCE.md. Naming a platform here is not a claim that it was tested.

Individual LMS platforms may differ slightly in how they interpret completion, attempts, bookmarking, and reporting. Validate against the one your learners will use.


Completion & Status

Does RevLearning automatically mark courses complete?

No.

RevLearning does not decide learner outcomes.

Your content decides learner state and reports it to the LMS.

Example:

runtime.MarkIncomplete();
await runtime.CommitAsync();

Later:

runtime.MarkCompleted();
await runtime.CommitAsync();

or:

runtime.MarkPassed();
await runtime.CommitAsync();

When should I mark a course incomplete?

Typically when learning begins.

Example:

await RevLearningRuntime.InitializeAsync(version);

runtime.MarkIncomplete();
await runtime.CommitAsync();

This tells the LMS that learning has started but is not yet complete.


When should I mark a course complete?

When your completion conditions have been met.

Examples:

  • Learner reaches the final screen.
  • Learner passes an assessment.
  • Learner finishes required modules.
  • Learner completes a simulation.

RevLearning does not define completion rules.

Your content does.


How do I report a score?

Write the raw score and its bounds through the data model:

runtime.Cmi.ScoreMin = 0;
runtime.Cmi.ScoreMax = 100;
runtime.Cmi.ScoreRaw = 85;

On SCORM 2004, also set the scaled score (−1 to 1). Many LMS use it as the primary value for pass/fail against the scaled passing score:

runtime.Cmi.ScoreScaled = 0.85f;

SCORM 1.2 has no scaled element, so ScoreScaled is read-only there (the getter derives a convenience value from raw/max).


Why does SCORM Cloud mark my course complete when I only launch and close it?

Some LMS environments and SCORM testing platforms may assign a completion state when a SCO launches and exits without explicitly reporting learner progress.

This behaviour originates from the LMS environment rather than RevLearning.

Recommended practice:

await RevLearningRuntime.InitializeAsync(version);

runtime.MarkIncomplete();
await runtime.CommitAsync();

when learning begins.

Then later:

runtime.MarkCompleted();
await runtime.CommitAsync();

or:

runtime.MarkPassed();
await runtime.CommitAsync();

when learning ends.


What is "Mark Incomplete On Initialize"?

An optional compatibility safeguard.

When enabled, RevLearning automatically writes an incomplete status during initialization for fresh launches.

This exists primarily for LMS environments or testing platforms that automatically mark newly launched SCOs as completed when no explicit status is reported.

Default:

Disabled

Most projects should leave this disabled and manage completion directly within course logic.


Should I enable "Mark Incomplete On Initialize"?

Usually no.

Enable it only if:

  • Your LMS automatically completes fresh launches.
  • You cannot control initialization behaviour.
  • Your LMS requires an explicit incomplete status immediately after launch.

If your course already writes learner status after initialization, the setting generally provides little benefit.


Bookmarks & Suspend Data

What's the difference between bookmarks and suspend data?

Bookmarks are simple resume locations.

Example:

Module3
Quiz2
Checkpoint5

Suspend data stores richer learner state.

Examples:

  • Scenario progress
  • Inventory
  • Assessment state
  • Branching decisions
  • Save-game style data

Does RevLearning compress suspend data?

Yes.

Suspend data helpers automatically:

  • Store raw JSON when it fits
  • Compress an oversized payload (gzip + base64) to make it fit
  • Apply the active standard's limit to the final stored value, envelope included

What they never do is trim. A payload that will not fit even compressed is refused, and reported as an error, and the last value that did fit is left exactly where it is. A trimmed payload is not smaller state, it is unparseable state: JsonUtility.FromJson rejects it, so on the next launch the resume path finds nothing usable and treats the learner as brand new — and the write has already destroyed the save that would have worked. Refusing costs the learner one checkpoint instead of all of them.

If you see that error, the fix is to store less per save — a key here and the bulk on your own backend — rather than to hope it fits next time.

Reading Cmi.SuspendData always gives back the payload you stored, with any RevLearning storage encoding already removed.


Can I store my own JSON?

Yes.

You can store raw JSON or serialize objects using the provided helpers.

RevLearning handles compression and restoration automatically.


How do I make a course resumable?

Resume depends on the SCORM exit value (cmi.core.exit in 1.2, cmi.exit in 2004). The LMS only preserves suspend data and the bookmark for the next launch when the session exits as suspend.

RevLearning handles this for you: a fresh Normal-mode launch defaults to LmsExit.Suspend, so an interrupted session is resumable without extra work. When the learner saves and leaves mid-course:

await runtime.SuspendAsync(); // marks exit = suspend and commits

When the learner is finished for good, end with a normal exit so the attempt closes cleanly:

runtime.MarkCompleted();
runtime.Cmi.Exit = LmsExit.Normal;
await runtime.CommitAsync();
await runtime.FinishAsync();

Note: resume across attempts is ultimately governed by the LMS's attempt and sequencing rules — RevLearning sets the exit value but does not manage attempt policy.

You cannot rehearse a resume in the Editor. Cmi.Entry is read-only on IScormDataModel — it reports what the LMS said about this launch, and the Editor stub always says AbInitio, because no attempt preceded it. So the first time your restore code runs against a real resume entry is on a real LMS. Validate it there: suspend, relaunch, and confirm Entry reads resume and your state comes back. The validation panel's Read Launch Context and Read Suspend Data buttons are there for exactly that, and it is step 13 of the runtime checklist in Tests~/CONFORMANCE.md.


Objectives

When should I use objectives?

Objectives are useful when you need to track progress independently from overall course completion.

Examples:

  • Individual modules
  • Assessments
  • Learning outcomes
  • Milestones

Do objectives complete the course automatically?

No.

Objectives and course completion are separate concepts.

Completing an objective does not automatically complete the SCO unless your content explicitly chooses to do so.

Writing an objective score does not assert an outcome either. LmsObjectives.SetScore(runtime, "quiz", raw: 20) records 20 and nothing else; the objective's success status stays whatever it was. Call SetSuccess to report one.


What can an objective express on each standard?

More on SCORM 2004 than on SCORM 1.2, and the difference is not something RevLearning can paper over.

SCORM 1.2 SCORM 2004
Identifier yes yes
Raw / min / max score yes yes
Scaled score no yes
Progress measure no yes
Success (passed/failed) yes — the one shared status element yes — its own element
Completion no yes — its own element
Description no yes

LmsObjectives.SetCompletion therefore warns and writes nothing on SCORM 1.2: the standard gives an objective a single status element, and RevLearning uses it for success, which is the more specific statement. On 1.2, report objective outcome with SetSuccess and keep completion at the course level.

Reading an objective back gives null for anything the LMS did not report, so an objective read and written straight back leaves those elements alone rather than asserting a value.


Interactions

What are interactions?

Interactions are learner actions recorded for LMS reporting.

Examples:

  • Multiple-choice questions
  • True/False questions
  • Quiz responses
  • Assessment answers

Do I need interactions?

No.

Many learning experiences only require:

  • Completion
  • Bookmarking
  • Suspend data

Interactions are optional.


Which interaction type and result tokens should I use?

Use the helpers and you do not have to think about it: RecordChoice and RecordTrueFalse write a type valid on both standards, and the correct flag becomes whichever result token the active standard accepts.

If you call LmsInteractions.Record with an explicit type, it reaches the LMS as given, so it must come from the active standard's vocabulary. The shared subset is valid on both: choice, true-false, fill-in, matching, performance, sequencing, likert, numeric. Note that multiple_choice is not a token in either standard, despite being a natural guess — the token for a multiple-choice question is choice.

The one thing the two standards genuinely spell differently is an incorrect answer: incorrect on SCORM 2004, wrong on SCORM 1.2. RevLearning translates whichever you supply, so content can hard-code either. Other tokens — neutral, unanticipated, a numeric result — pass through untouched.

An element the LMS refuses is reported as a warning naming the element and the decoded error. GetInteractions() will still show the interaction on SCORM 1.2, because that comes from RevLearning's local mirror rather than from the LMS, so the console is where you find out whether the LMS actually took it.


Is every interaction committed immediately?

On SCORM 1.2, yes, by default — that is the "Commit On Each Interaction (SCORM 1.2)" project setting. It is safer against a crash, but a 30-question quiz becomes 30 commits and many platforms do a server round trip for each one.

SCORM 2004 does not read the setting. Its interaction writes go out with whatever the next commit carries. If per-question durability matters on 2004, call SaveProgressAsync() yourself after recording.


Why is Cmi.PassingScore always null?

Because nothing put a value there.

PassingScore reads cmi.student_data.mastery_score on SCORM 1.2 and cmi.scaled_passing_score on SCORM 2004. Both are initialised by the LMS from the manifest, and RevLearning's manifest builder does not emit one — there is no setting for it, and the elements are optional, so a conformant LMS reports nothing.

So for every package built by the in-box packer, this is null. It is only non-null if you author the manifest yourself, or your LMS lets an administrator set a passing score per course.

Decide pass and fail in your content. RevLearning never gates anything on PassingScore; the property exists to surface what the LMS says, and for in-box packages the honest answer is that the LMS says nothing.


xAPI

How does RevLearning deliver xAPI statements reliably?

Network calls to a Learning Record Store (LRS) can fail — the connection drops, the server is briefly unavailable, or the learner closes the tab mid-send.

By default, the xAPI runtime created by XApiRuntimeFactory.Create(config) wraps the transport in a resilient queue that:

  • Retries failed sends with exponential backoff.
  • Holds undelivered statements in an ordered (FIFO) queue and flushes them once delivery succeeds again.
  • Persists the queue (via PlayerPrefs, backed by IndexedDB on WebGL) so statements survive a page reload or crash.
// Resilient delivery (default).
var runtime = XApiRuntimeFactory.Create(config);

// Opt out, for fire-and-forget delivery.
var runtime = XApiRuntimeFactory.Create(config, useOfflineQueue: false);

// Tune retry/queue behaviour.
var runtime = XApiRuntimeFactory.Create(config, new XApiQueueOptions
{
    MaxAttempts = 5,
    BaseRetryDelayMs = 1000,
    MaxQueuedStatements = 512
});

The backlog is bounded by MaxQueuedStatements; when the limit is exceeded the oldest statement is dropped and a warning is logged.

What the return value means

SendStatementAsync, and every XApiStatements helper, returns true when the statement has been accepted for delivery — either the LRS has it, or the queue does and will retry. It returns false only when the statement will never be delivered: invalid input, a disconnected runtime, or an LRS that permanently refused it.

Do not resend on false. Anything that can still arrive is already the runtime's responsibility, and the runtime replays its own retries byte-identically so the LRS deduplicates them. Calling a helper again builds a new statement with a new id, which the LRS cannot deduplicate — so retrying a PassedAsync on a flaky connection stores the pass several times.

Use PendingStatementCount to see whether anything is still outstanding, and FlushPendingAsync to push it at a natural pause.

Bad credentials do not cost you the backlog

A 401, 403 or 407 is about the credentials, not about a statement, so delivery stops and keeps everything queued. A working token on the next launch delivers the lot. Since the recommended pattern is a short-lived per-launch token, an expired one is routine rather than exotic — and dropping statements over it would lose a whole session's records.

A 400-class fault is different: the LRS is refusing that statement and always will, so it is dropped, loudly, and the ones behind it go through.

Durability is best-effort

Persistence uses PlayerPrefs, and on WebGL that is a single IndexedDB store sharing a browser-imposed budget of roughly 1 MB with everything else your page keeps there. A large offline backlog can approach it. A failed save is reported as an error, but the statements it could not save are then only in memory — so a long offline session with a big backlog is worth flushing at a natural pause rather than leaving to the end.

PlayerPrefs.Save() on WebGL also only starts the browser's write; it does not wait for it. The injected page helper commits SCORM state on tab close, which is synchronous and reliable. It does not flush the xAPI queue, and it cannot: an LRS request needs authentication headers that no unload-time browser API can carry. So a statement recorded on the results screen a moment before the tab closes can be lost. If the last few statements of a session matter, await FlushPendingAsync() before you show that screen.


Who is the learner in an xAPI statement?

An LRS identifies a learner by their inverse functional identifier — mbox or account — not by their display name. Whatever you put there is the learner as far as every report is concerned, and two statements sharing an identifier are the same person.

That makes the actor the single most consequential value in the xAPI path. If a build ships with a fixed default address, every learner who runs it is recorded as the same agent, and the LRS correctly merges them into one record — with nothing left in the statements to separate them again.

If the course is launched through SCORM, the LMS has already told you who the learner is:

var actor = XApiActor.FromLmsLearner(scormRuntime, "https://lms.example.com");

The second argument identifies your LMS and qualifies the learner id it reports — two platforms can both call someone 1024, and this is what keeps them apart. Otherwise supply the identity from wherever your application knows it:

var actor = XApiActor.FromAccount(displayName, "https://lms.example.com", userId);
var actor = XApiActor.FromEmail(displayName, "learner@yourcompany.com");

account is usually the better choice for LMS-launched content, because it identifies the learner by their platform id rather than putting their email address into every statement.

An actor carrying no identifier at all is refused by InitializeAsync rather than being sent, since a conformant LRS would reject every statement with 400.


What happens to statements that cannot be delivered?

They are queued, retried with backoff, and persisted, so a learner who loses connectivity mid-course does not lose their record.

Delivery distinguishes failures worth retrying from failures that never will be. A dropped connection, a 5xx, a timeout: retried. A 400 for a malformed statement: logged as an error and dropped, because retrying it forever would block every statement behind it in the queue.

A 401 or 403 is treated differently from a 400, because it is a fact about the credentials rather than about a statement: delivery halts and the whole backlog is kept for the next launch. Dropping on 401 — which is what this used to do — meant one flush with an expired token attempted each statement once, discarded it, and erased the persisted queue.

Each statement carries a client-generated UUID, so redelivering one the LRS already stored is recognised (409) rather than duplicated.

The backlog is scoped to the learner, so a shared machine cannot hand one person's undelivered statements to the next. You can inspect and control it:

if (runtime.PendingStatementCount > 0)
    await runtime.FlushPendingAsync();

runtime.ClearPendingStatements();   // shared devices: discard rather than keep

How should I provide LRS credentials?

Browser builds have no secure local storage — anything saved on the XApiEndpointConfig asset is baked into the build and is readable by anyone who downloads it.

For production, leave the inspector Username/Password fields blank and supply credentials at launch instead (for example from an LMS launch URL, a fetched token, or a secure backend):

config.SetRuntimeCredentials(user, pass);

Runtime-supplied credentials are never serialized, take precedence over the inspector values, and can be cleared with config.ClearRuntimeCredentials().

The inspector fields remain available for local testing only.


Deploying to an LMS

My xAPI statements never arrive from a WebGL build

Almost always CORS.

A browser build sends Content-Type: application/json, an X-Experience-API-Version header, and usually Authorization. Any one of those makes the browser send a preflight OPTIONS request first, and the LRS has to answer it. If it does not, the real request is never sent — the browser blocks it, and the console shows a CORS error rather than anything from RevLearning.

The LRS must allow:

  • the origin your course is served from (which is the LMS's domain, not yours — check what the LMS actually serves the SCO from, including any content-delivery domain);
  • the methods POST and OPTIONS;
  • the headers Content-Type, Authorization, X-Experience-API-Version.

Most hosted LRS products expose this as an allowed-origins list. If yours cannot be configured, put a small proxy of your own between the course and the LRS and point XApiEndpointConfig.endpoint at that.

Statements that cannot be sent are not lost — they queue and retry — so a CORS problem shows up as a PendingStatementCount that never falls rather than as missing data, until the learner runs out of sessions to retry in.


The course launches to a blank screen or hangs at the loader

Usually compression headers.

Unity's WebGL builds are gzip- or Brotli-compressed by default, and the server must serve them with the matching Content-Encoding. Many LMS platforms serve uploaded package files as-is, so the browser receives compressed bytes it does not know to decompress.

The symptom is unmistakable once you know it: the browser reports illegal character U+001F, or Unity's loader says "Unable to parse Build/…framework.js.gz". U+001F is the first byte of the gzip magic number — the browser has been handed compressed bytes and is trying to read them as JavaScript.

Observed on SCORM Cloud with a default Unity build, so assume it applies anywhere you cannot configure the server.

Options, best first:

  1. Turn on Decompression Fallback (Player Settings > Publishing Settings), leaving Compression Format on Gzip. Unity then ships a decompressor inside the loader and names the payload .unityweb instead of .gz, so the files stay small and no server configuration is needed at all. This is the right answer for LMS deployment.
  2. Set Compression Format to Disabled. Works everywhere, but the download is two to three times larger.
  3. Leave compression on without the fallback only when you control the hosting and can guarantee Content-Encoding is set. Brotli is not the problem here and Gzip is not the cure — the failure is compression without a decompressor, whichever format you pick.

RevLearning refuses to build a package with this combination. The post-build packer checks it on every WebGL build, including a plain Build Settings build, and emits no zip at all rather than one that reports success and hangs at the loader. The RevLearning build menu offers to turn Decompression Fallback on for you, and — for the one legitimate case above, hosting you control — lets you package anyway as an explicit, acknowledged choice. The validator flags it too.

This used to be a console error that did not stop anything: the build continued, the packer wrote the zip, and the last thing the tool said was "SCORM package created".

Also turn Data Caching off unless you understand its interaction with course updates: learners who have run an earlier version may keep getting it.


Testing by hosting the build folder directly

The index.html in your build output is not the one the LMS gets.

The SCORM packer copies the build to a staging folder, injects the page helper there, and zips that. So the raw build folder has no unload-commit handling and no SCORM diagnostics — testing against it will not reproduce what learners see. Test with the generated .zip.


What RevLearning does not do

Deliberate scope limits, so you can plan around them:

  • One SCO per package. The manifest builder writes a single-SCO course. No multi-SCO packaging, and no aggregation across SCOs.
  • No sequencing or navigation. No imsss sequencing rules, no adl.nav.request, no control over what the LMS's next/previous buttons do.
  • No attempt management. Attempts, retakes, resets and review policy belong to the LMS.
  • xAPI is send-only. Statements go out; there is no statement querying, no State API, no Agent or Activity Profile API.
  • No offline SCORM. The SCORM side needs a live LMS API object. Only xAPI queues for later delivery.
  • No mastery score in the manifest. Cmi.PassingScore reads a value the LMS initialises from the manifest, and the builder emits none — so it is null for in-box packages. Decide pass and fail in your content.
  • No resume rehearsal in the Editor. Cmi.Entry reports what the LMS said, and the stub always says this is a fresh launch. Restore code has to be validated against a real LMS.

Anything richer than a single-SCO course is better authored in a dedicated SCORM tool that packages your WebGL build as one of its resources.


What is Assets/Resources/RevLearning_RuntimeConfig.asset?

A generated file, written into your project on editor load and kept in sync with Project Settings → RevGaming → RevLearning.

It exists because the runtime needs those settings at run time, and project settings are editor-only. The Resources folder is how a player build gets at them.

Two things to know about it:

  • Project Settings is the source of truth. Editing the asset in the Inspector works until the next sync, which reverts it. Change the settings, not the asset.
  • It is regenerated if deleted, so it will reappear. Check it into version control like any other generated-but-required asset; it changes only when you change a setting.

To remove it for good, remove the package. Deleting the asset alone will not stick while RevLearning is installed.


Suspend data format

Your payload is not always what lands in cmi.suspend_data.

On SCORM 2004 it is stored as given, compressed if it would otherwise exceed the element limit.

On SCORM 1.2 it is wrapped in a small envelope so RevLearning can mirror interactions alongside it for readback. Stored values are one of:

Prefix Meaning
(none) Plain data, written by something other than RevLearning
REVLEARNING: Envelope: your payload plus the interaction mirror, as JSON
REVLEARNING:GZIP64: The above, gzipped and Base64-encoded

Read it back through TryGetSuspendJson / TryGetSuspendObject, which unwrap whichever form is present. Reading Cmi.SuspendData directly returns your payload with the envelope already removed.

The 4096-character SCORM 1.2 limit covers the whole envelope, so your payload competes with the interaction mirror for it. When space runs short the mirror is dropped first, then the payload is compressed; if it still will not fit, the write is refused and reported rather than truncated, because a trimmed payload will not deserialize and would destroy the last one that did.

The prefixes are part of the stored format. If they ever change, existing learners will have data written with the old ones, so any change has to keep reading them.


LMS Responsibility vs RevLearning Responsibility

Who owns learner records?

The LMS.

RevLearning reports learner state.

The LMS stores learner state.


Who owns completion rules?

Your content.

RevLearning never decides whether a learner has passed, failed, completed, or progressed.


Who owns attempt management?

The LMS.

Different LMS platforms handle:

  • Attempts
  • Reviews
  • Resets
  • Retakes

differently.

RevLearning reports learner progress but does not manage LMS attempt policies.


Troubleshooting

How do I tell whether an LMS call actually succeeded?

Read ILmsRuntime.LastError immediately after the operation you want to check. It mirrors the SCORM GetLastError / LMSGetLastError value for the most recent call, so check it before making another call.

runtime.Cmi.LessonStatus = LessonStatus.Completed;

if (runtime.LastError != LmsError.None)
    Debug.LogError($"[Course] LMS write failed: {runtime.DescribeLastError()}");

LmsError.None means the last call succeeded. The editor/test stub runtime always reports LmsError.None, since it does not talk to a real LMS.

A write RevLearning blocks before it reaches the LMS — after FinishAsync, or outside a live session — does not refresh LastError, because the LMS was never asked and its last error is still whatever it was. Those are reported as console warnings instead. So LastError answers "what did the LMS say about my last call", not "did my last line of code do anything", and a single check after a batch of writes can read None precisely when writes are being discarded.

Use DescribeLastError() rather than LastError.Describe(). SCORM 1.2 and SCORM 2004 assign different meanings to several of the same numeric codes — 404 is "write only" in one and "read only" in the other — and SCORM 2004 adds lifecycle codes (103 already initialized, 133 store after termination, 143 commit after termination) that SCORM 1.2 has no equivalent for. DescribeLastError() reads the version from the runtime, so it cannot pick the wrong table.


My LMS behaviour differs from SCORM Cloud. Is that normal?

Yes.

Different LMS platforms may interpret completion, review mode, attempts, and reporting differently.

Always validate behaviour within the LMS your learners will actually use.

SCORM Cloud is an excellent testing tool, but it should not be treated as the sole source of truth for LMS behaviour.


How should I think about RevLearning?

Simple:

Your Content
      ↓
 RevLearning
      ↓
 Learning Standard
      ↓
 LMS

Your content decides what happened.

RevLearning reports it.

The LMS records it.