pnpm create svelte-scorm my-project
or using npx:
npx create-svelte-scorm my-project
Define your course in src/course.ts using three helper functions:
import { defineCourse, defineLesson, defineSlide } from '$core/player/types.js';
import CourseFrame from './course/layouts/CourseFrame.svelte';
import LessonFrame from './course/layouts/LessonFrame.svelte';
export const course = defineCourse({
id: 'my-course',
title: 'My Course',
description: 'Optional description',
masteryScore: 80, // optional, score to pass
minScore: 0,
maxScore: 100,
sequencing: 'linear', // 'linear' (default) or 'free'
storageMode: 'standard', // 'standard' (default) or 'chunked'
layout: CourseFrame, // optional course-level layout
loadingComponent: MyLoader, // optional custom loading screen
lessons: [
defineLesson({
id: 'intro',
title: 'Introduction',
description: 'Optional',
layout: LessonFrame, // optional lesson-level layout
slides: [
defineSlide({
id: 'welcome',
title: 'Welcome', // optional, falls back to id
description: 'Optional slide description',
component: () => import('./course/slides/welcome/WelcomeSlide.svelte')
}),
defineSlide({
id: 'quiz',
title: 'Quiz',
completionMode: 'manual', // requires explicit markPassed()
component: () => import('./course/slides/quiz/Quiz.svelte')
})
]
})
]
});
| Type | Fields |
|---|---|
CourseDefinition |
id, title, description?, masteryScore?, minScore, maxScore, sequencing?, storageMode?, layout?, loadingComponent?, lessons |
LessonDefinition |
id, title, description?, layout?, slides |
SlideDefinition |
id, title?, description?, completionMode?, component (lazy import) |
LayoutComponent |
Component<{ children: Snippet }> — wraps child content |
Each slide gets enhanced at runtime with:
type CourseSlide = SlideDefinition & {
index: number; // 0-based position in entire course
total: number; // total slide count
lessonId: string;
lessonTitle: string;
pathname: `/${string}`; // e.g. '/intro/welcome'
completionMode: SlideCompletionMode; // resolved: 'auto' or 'manual'
};
Controls whether learners must complete slides in order.
defineCourse({
sequencing: 'linear' // default — slides gate in order
// sequencing: 'free', // all slides accessible at any time
});
goto() silently refuses navigation to locked slidesgoNext() refuses if the current slide is not passedgoPrevious() always works — backward navigation is unrestrictedcoursePlayer.isLocked(slide); // true if the slide can't be navigated to yet
coursePlayer.getSlideStatus(slide); // 'passed' | 'failed' | 'incomplete' | 'not attempted'
Each slide has a completionMode that determines how it gets marked as passed.
| Mode | Behavior | Use for |
|---|---|---|
'auto' |
Marked as passed automatically on visit (default) | Content/info slides |
'manual' |
Must call markPassed() explicitly from the slide |
Quiz/assessment slides |
useSlideCompletion() hookFor manual-completion slides, use this hook inside your slide component:
import { useSlideCompletion } from '$core/player';
const completion = useSlideCompletion();
| Property / Method | Type | Description |
|---|---|---|
status |
SlideObjectiveStatus |
Reactive: 'passed', 'failed', 'incomplete', 'not attempted' |
isPassed |
boolean |
Reactive: slide is passed |
isFailed |
boolean |
Reactive: slide is failed |
score |
number | undefined |
Reactive: slide score (0–100) |
markPassed() |
void |
Mark the slide as passed |
markFailed() |
void |
Mark the slide as failed |
markPassedWithScore(score) |
void |
Mark passed with a score (0–100) |
A slide that has been marked 'passed' is never downgraded to 'failed' or 'incomplete'.
For simple cases you can use useSlideCompletion() directly, but for quizzes with scoring, thresholds, and course score integration, use defineTest() instead (see Quiz & Test System).
<script lang="ts">
import { defineMultiChoiceQuestion, defineTest } from '$core/quiz';
const q1 = defineMultiChoiceQuestion({
id: 'scorm-acronym',
question: 'What does SCORM stand for?',
options: [
{ key: 'A', label: 'Shareable Content Object Reference Model' },
{ key: 'B', label: 'Standard Course Object Resource Manager' }
],
correctAnswer: 'A'
});
// defineTest handles slide completion, scoring, and course score automatically
const test = defineTest({
id: 'my-quiz',
questions: [q1],
passThreshold: 1.0 // 100% to pass (default)
});
</script>
completionMode: 'manual' in course.ts, then use defineTest() in the component — it handles slide completion automatically'linear' — no config needed for gated navigationimport { coursePlayer } from '$core/player';
| Property | Type | Description |
|---|---|---|
slides |
CourseSlide[] |
All slides in the course |
activeSlide |
CourseSlide | undefined |
Currently displayed slide (reactive) |
canGoNext |
boolean |
Reactive: next slide exists and is reachable |
canGoPrevious |
boolean |
Reactive: previous slide exists |
firstPath |
`/${string}` |
Path to the first slide |
isNavigating |
boolean |
True while navigating (reactive) |
await coursePlayer.goto('/lesson-id/slide-id'); // navigate to path (refused if locked)
await coursePlayer.goNext(); // next slide (refused if canGoNext is false)
await coursePlayer.goPrevious(); // previous slide (refused if canGoPrevious is false)
coursePlayer.isLocked(slide); // is this slide locked by sequencing?
coursePlayer.getSlideStatus(slide); // 'passed' | 'failed' | 'incomplete' | 'not attempted'
All metrics are reactive ($derived). Access via coursePlayer:
coursePlayer.course| Field | Type | Description |
|---|---|---|
slideNumber |
number |
Current slide (1-based) |
totalSlides |
number |
Total slides in course |
slidesCompleted |
number |
Slides with status 'passed' |
progress |
number |
Percentage 0–100 (based on slides completed) |
lessonNumber |
number |
Current lesson (1-based) |
totalLessons |
number |
Total lessons |
coursePlayer.lesson| Field | Type | Description |
|---|---|---|
id |
string |
Current lesson ID |
title |
string |
Current lesson title |
slideNumber |
number |
Slide position within lesson (1-based) |
totalSlides |
number |
Slides in current lesson |
slidesCompleted |
number |
Completed slides in current lesson |
progress |
number |
Lesson progress 0–100 (based on completed) |
coursePlayer.slide| Field | Type | Description |
|---|---|---|
id |
string |
Current slide ID |
title |
string |
Current slide title |
elapsedMs |
number |
Time on current slide (ms) |
coursePlayer.session| Field | Type | Description |
|---|---|---|
elapsedMs |
number |
Total session time (ms), ticks every 1s |
<p>Progress: {coursePlayer.course.progress}%</p>
<p>
Slide {coursePlayer.course.slideNumber} of {coursePlayer.course.totalSlides}
</p>
<p>
Completed: {coursePlayer.course.slidesCompleted} / {coursePlayer.course.totalSlides}
</p>
<p>Time on slide: {Math.round(coursePlayer.slide.elapsedMs / 1000)}s</p>
import { scormState } from '$core/scorm';
| Property | Type | Description |
|---|---|---|
isConnected |
boolean |
SCORM API found |
isInitialized |
boolean |
Successfully initialized |
version |
'1.2' | '2004' | undefined |
Detected SCORM version |
mode |
'lms' | 'dev' |
Running against LMS or localStorage |
location |
string |
Current slide path (read/write, auto-saved) |
sessionStartTime |
number |
Timestamp when course started |
sessionElapsedMs |
number |
Elapsed time (read-only) |
scormState.student.id; // learner ID from LMS
scormState.student.name; // learner name from LMS
scormState.session.mode; // 'normal' | 'browse' | 'review'
scormState.session.credit; // 'credit' | 'no-credit'
scormState.session.entry; // 'ab-initio' | 'ab_initio' | 'resume' | ''
scormState.commit(); // force save to LMS
scormState.terminate(); // end session (called automatically on page unload)
The quiz system provides reusable Question and Test abstractions. Course authors define questions, group them into a test, and everything else — slide completion, weighted scoring, pass thresholds, and course score aggregation — is handled automatically.
defineMultiChoiceQuestion()import { defineMultiChoiceQuestion } from '$core/quiz';
const q = defineMultiChoiceQuestion({
id: 'my-question',
question: 'What does SCORM stand for?',
options: [
{ key: 'A', label: 'Shareable Content Object Reference Model' },
{ key: 'B', label: 'Standard Course Object Resource Manager' },
{ key: 'C', label: 'Synchronized Content Online Reference Module' },
{ key: 'D', label: 'Simple Course Object Runtime Model' }
],
correctAnswer: 'A',
weight: 2 // optional, default 1
});
defineTrueFalseQuestion()import { defineTrueFalseQuestion } from '$core/quiz';
const q = defineTrueFalseQuestion({
id: 'tf-question',
question: 'SCORM 2004 supports sequencing and navigation.',
correctAnswer: 'true', // "true" or "false"
weight: 1 // optional, default 1
});
Question interfaceBoth factories return an object conforming to Question. Future question types (fill-in, matching, etc.) implement the same interface.
| Property / Method | Type | Description |
|---|---|---|
id |
string |
Short ID provided by the author |
fullId |
string |
Globally unique: q:{lessonId}:{slideId}:{id} |
question |
string |
Question text |
weight |
number |
Weight for scoring (default 1) |
selectedAnswer |
string | undefined |
Writable reactive state — bind UI selections here |
isPassed |
boolean |
Reactive: answered correctly |
attempts |
RecordedInteraction[] |
All recorded attempts |
handleSubmit() |
void |
Submits selectedAnswer to SCORM |
Set the user's selection directly on the question:
<button onclick={() => (q.selectedAnswer = 'A')}>Option A</button>
defineTest()Groups questions into a test with weighted scoring and a pass threshold. The test owns slide completion — you never call useSlideCompletion() yourself.
import { defineMultiChoiceQuestion, defineTest } from "$core/quiz";
const q1 = defineMultiChoiceQuestion({ id: "q1", ..., weight: 2 });
const q2 = defineMultiChoiceQuestion({ id: "q2", ... });
const test = defineTest({
id: "knowledge-check",
questions: [q1, q2],
passThreshold: 0.5, // 50% to pass (0–1, default 1.0)
});
TestHandle properties and methods| Property / Method | Type | Description |
|---|---|---|
id |
string |
Test ID |
fullId |
string |
Globally unique: test:{lessonId}:{slideId}:{id} |
questions |
Question[] |
All questions in the test |
score |
number |
Weighted score 0–100% (reactive) |
isPassed |
boolean |
Reactive: score >= passThreshold * 100 |
isSubmitted |
boolean |
Reactive: test has been submitted |
allAnswered |
boolean |
Reactive: every question has a selectedAnswer |
hasIncorrect |
boolean |
Reactive: submitted with at least one wrong answer |
passedCount |
number |
Number of correctly answered questions |
answeredCount |
number |
Number of questions with a selected answer |
questionResult(q) |
QuestionResult |
'correct', 'incorrect', or undefined |
submit() |
void |
Submits all questions' selectedAnswers |
retry() |
void |
Clears submitted state and all selections |
defineTest() handles automaticallyuseSlideCompletion() internally; marks the slide as passed (with score) or sets the score on failurescore = (earned weight / total weight) * 100isPassed when score >= passThreshold * 100selectedAnswer from persisted attemptsscormState.score.raw via the test registry (scaled to the course's minScore–maxScore range)Every defineTest() call registers the test with a global testRegistry. The registry:
minScore–maxScore range and writes it to scormState.score.rawscormState.completion.setPassed() when all tests passNo manual wiring needed. If your course has two quiz slides each with a defineTest(), the course score is the average of their two scores.
<script lang="ts">
import { defineMultiChoiceQuestion, defineTest } from '$core/quiz';
import { Button } from '$lib/components/ui/button/index.js';
const q1 = defineMultiChoiceQuestion({
id: 'scorm-acronym',
question: 'What does SCORM stand for?',
options: [
{ key: 'A', label: 'Shareable Content Object Reference Model' },
{ key: 'B', label: 'Standard Course Object Resource Manager' }
],
correctAnswer: 'A',
weight: 2
});
const q2 = defineMultiChoiceQuestion({
id: 'scorm-version',
question: 'Which SCORM version introduced sequencing?',
options: [
{ key: 'A', label: 'SCORM 1.2' },
{ key: 'B', label: 'SCORM 2004' }
],
correctAnswer: 'B'
});
const test = defineTest({
id: 'knowledge-check',
questions: [q1, q2],
passThreshold: 0.5
});
</script>
{#each test.questions as q, qi (q.id)}
{@const result = test.questionResult(q)}
<h2>{qi + 1}. {q.question}</h2>
{#each q.options as option (option.key)}
<button disabled={test.isSubmitted} onclick={() => (q.selectedAnswer = option.key)}>
{option.key}. {option.label}
</button>
{/each}
{#if result === 'correct'}<p>Correct!</p>{/if}
{#if result === 'incorrect'}<p>Incorrect — try again.</p>{/if}
{/each}
<p>{test.answeredCount} of {test.questions.length} answered</p>
{#if test.isPassed}
<p>Passed — {Math.round(test.score)}%</p>
{:else if test.hasIncorrect}
<p>{test.passedCount} of {test.questions.length} correct</p>
<Button onclick={() => test.retry()}>Retry</Button>
{:else}
<Button disabled={!test.allAnswered} onclick={() => test.submit()}>Submit</Button>
{/if}
To add a new question type (e.g., fill-in-the-blank):
src/_core/quiz/define-fill-in.svelte.tsQuestionscormState.store.recordInteraction({ type: "fill-in", ... })src/_core/quiz/index.tsThe new type works with defineTest() immediately — no changes to the test or scoring system.
These are two separate concepts that serve different purposes.
scormState.score)The course score is the overall grade reported to the LMS via cmi.core.score (SCORM 1.2) or cmi.score (SCORM 2004). This is what the LMS displays in its gradebook.
scormState.score.raw; // get/set — clamped to [min, max]
scormState.score.min; // read-only (set from course definition)
scormState.score.max; // read-only (set from course definition)
scormState.score.scaled; // read-only, SCORM 2004 only (auto-calculated)
minScore / maxScore in defineCourse()defineTest(), the course score is set automatically via the test registry (see Quiz & Test System)masteryScore to determine pass/failscormState.score.raw = 85;
// SCORM 1.2: raw = 85
// SCORM 2004: raw = 85, scaled = 0.85 (auto)
useSlideCompletion().score)The slide score is a per-slide value (0–100) stored in SCORM objectives. It is used internally for sequencing and progress tracking.
const completion = useSlideCompletion();
completion.markPassedWithScore(90); // stores score=90, min=0, max=100 on this slide's objective
completion.score; // 90
cmi.objectives| Scenario | Use |
|---|---|
| Quiz slides with automatic scoring | defineTest() — handles both slide and course score |
| Setting the overall grade manually | scormState.score.raw = 85 |
| Tracking how a learner scored on one slide | completion.markPassedWithScore(90) |
| Custom grading from slide scores | Read slide scores, compute average, set scormState.score.raw |
These are also two separate concepts.
scormState.completion)The course completion is the overall status reported to the LMS via cmi.core.lesson_status (SCORM 1.2) or cmi.completion_status / cmi.success_status (SCORM 2004). This is what the LMS uses to mark the course as done.
scormState.completion.status; // 'completed' | 'incomplete' | 'not attempted' | 'unknown'
scormState.completion.success; // 'passed' | 'failed' | 'unknown'
| Method | Status | Success |
|---|---|---|
setCompleted() |
completed | (unchanged) |
setIncomplete() |
incomplete | unknown |
setPassed() |
completed | passed |
setFailed() |
completed | failed |
useSlideCompletion() / coursePlayer.getSlideStatus())Slide completion is a per-slide status stored in SCORM objectives. It drives sequencing (which slides are locked/unlocked) and progress tracking.
// From inside a slide component:
const completion = useSlideCompletion();
completion.status; // 'passed' | 'failed' | 'incomplete' | 'not attempted'
completion.isPassed; // true if passed
completion.markPassed(); // mark this slide as passed
// From anywhere:
coursePlayer.getSlideStatus(slide); // same status values
coursePlayer.isLocked(slide); // true if previous slide not passed (linear mode)
'auto' slides (passed on visit)useSlideCompletion() for 'manual' slides| Scenario | Use |
|---|---|
| Telling the LMS the learner finished the course | scormState.completion.setPassed() |
| Gating navigation so slide 3 requires slide 2 to pass | Set completionMode: 'manual' + useSlideCompletion() |
| Checking if all slides are done to decide course status | Read coursePlayer.course.slidesCompleted === coursePlayer.course.totalSlides |
Key-value storage that persists in SCORM suspend_data.
const store = scormState.store;
store.setString('theme', 'dark');
store.getString('theme'); // 'dark' | undefined
store.setNumber('fontSize', 16);
store.getNumber('fontSize'); // 16 | undefined
store.setBoolean('soundOn', true);
store.getBoolean('soundOn'); // true | undefined
store.setObject('prefs', { a: 1 });
store.getObject<{ a: number }>('prefs'); // { a: 1 } | undefined
store.has('theme'); // true
store.delete('theme');
All set operations auto-persist immediately.
store.variables; // Record<string, unknown> — reactive
Record learner responses to questions. Written to the SCORM cmi.interactions data model for LMS reporting.
store.recordInteraction({
id: 'q:lesson1:slide1:question1',
type: 'choice', // 'true-false' | 'choice' | 'fill-in' | 'matching' | etc.
learnerResponse: 'A',
correctResponse: 'B',
result: 'incorrect', // 'correct' | 'incorrect' | 'unanticipated' | 'neutral'
weighting: 1 // points
// optional: latency, objectiveId, description (2004 only)
});
store.interactionHistory; // Record<string, RecordedInteraction[]>
// RecordedInteraction:
// { id, type, learnerResponse, correctResponse, result, weighting, timestamp }
Note:
cmi.interactionsare write-only on most SCORM 1.2 LMS. History is available within a session but not across sessions. Use objectives for cross-session state.
Automatically created when you recordInteraction(). Persist across sessions via cmi.objectives.
store.isObjectivePassed('q:lesson1:slide1:question1'); // boolean
store.getObjective('q:lesson1:slide1:question1');
// { id, status: 'passed' | 'failed' | 'incomplete' | 'not attempted', score? }
result === 'correct' -> objective status = 'passed'result === 'incorrect' -> objective status = 'failed''passed' objective is never downgraded to 'failed'q: prefix for quiz objectives: q:lessonId:slideId:questionIdSlide completion state is also stored as objectives with IDs in the format slide:{lessonId}:{slideId}. These are managed automatically by the player — you don't need to interact with them directly.
Set in defineCourse({ storageMode: ... }):
| Mode | How | Limits | Best for |
|---|---|---|---|
'standard' (default) |
Variables compressed in suspend_data |
4KB (1.2), 64KB (2004) | Most courses |
'chunked' |
Overflow data split across cmi.interactions records |
Virtually unlimited | Large courses with lots of stored data |
src/
course.ts <- Define your course here
App.svelte <- Root component (don't edit usually)
_core/
player/
CourseShell.svelte <- Handles init, routing, lifecycle (internal)
player.svelte.ts <- coursePlayer singleton
player-metrics.svelte.ts <- Reactive metrics
slide-completion.svelte.ts <- useSlideCompletion() hook
slide-context.svelte.ts <- Slide identity context (internal)
router.svelte.ts <- sv-router setup (internal)
types.ts <- defineCourse/Lesson/Slide, type definitions
scorm/
state.svelte.ts <- scormState singleton
persistent-store.svelte.ts <- Key-value + interactions + objectives + slide tracking
score-state.svelte.ts <- Score management
completion-state.svelte.ts <- Completion management
storage/ <- Storage engines (internal)
quiz/
types.ts <- Question, TestDefinition, TestHandle interfaces
define-multi-choice.svelte.ts <- defineMultiChoiceQuestion factory
define-true-false.svelte.ts <- defineTrueFalseQuestion factory
define-test.svelte.ts <- defineTest factory
test-registry.svelte.ts <- Global test registry + course score sync
course/
layouts/
CourseFrame.svelte <- Course-level layout (sidebar + header)
LessonFrame.svelte <- Lesson-level layout (title + prev/next nav)
slides/ <- Your slide components go here
lib/
components/
app-sidebar.svelte <- Course navigation sidebar (lock/check icons)
ui/ <- shadcn-svelte components (button, sidebar, etc.)
CourseFrame — wraps the entire course. Provides sidebar navigation, header, progress bar.
LessonFrame — wraps each lesson's slides. Shows lesson title, previous/next buttons.
Both accept a children snippet:
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
<div class="my-layout">
{@render children()}
</div>
| Dev Mode | LMS Mode | |
|---|---|---|
| API | localStorage | SCORM API |
| Detection | No SCORM API found | window.API or window.API_1484_11 |
| Storage prefix | scorm-dev:{courseId}: |
N/A |
| Student info | Empty strings | From LMS |
| Check | scormState.mode === 'dev' |
scormState.mode === 'lms' |
Dev mode activates automatically when no LMS is detected (e.g., running vite dev). All data persists in the browser's localStorage.