svelte-scorm-starter Svelte Themes

Svelte Scorm Starter

SCORM Template — Course Author Reference

To initialize a new template project from this repo, run:

pnpm create svelte-scorm my-project

or using npx:

npx create-svelte-scorm my-project

Table of Contents


Course Definition

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')
                })
            ]
        })
    ]
});

Types

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

Runtime: CourseSlide

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'
};

Sequencing

Controls whether learners must complete slides in order.

defineCourse({
    sequencing: 'linear' // default — slides gate in order
    // sequencing: 'free', // all slides accessible at any time
});

How linear sequencing works

  • A slide is locked if the previous slide has not been passed
  • goto() silently refuses navigation to locked slides
  • goNext() refuses if the current slide is not passed
  • goPrevious() always works — backward navigation is unrestricted
  • The first slide is never locked

How free sequencing works

  • All slides are accessible at any time
  • No navigation restrictions

Checking lock state

coursePlayer.isLocked(slide); // true if the slide can't be navigated to yet
coursePlayer.getSlideStatus(slide); // 'passed' | 'failed' | 'incomplete' | 'not attempted'

Slide Completion

Each slide has a completionMode that determines how it gets marked as passed.

Completion modes

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() hook

For manual-completion slides, use this hook inside your slide component:

import { useSlideCompletion } from '$core/player';

const completion = useSlideCompletion();

Returned handle

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'.

Example: quiz slide with manual completion

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>

Zero-config behavior

  • Content slides auto-complete on visit — no code needed
  • Quiz slides: add completionMode: 'manual' in course.ts, then use defineTest() in the component — it handles slide completion automatically
  • Default sequencing is 'linear' — no config needed for gated navigation

Course Player

import { coursePlayer } from '$core/player';

Properties

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)

Methods

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'

Player Metrics

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

Usage

<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>

SCORM State

import { scormState } from '$core/scorm';

Properties

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)

Student Info

scormState.student.id; // learner ID from LMS
scormState.student.name; // learner name from LMS

Session Info

scormState.session.mode; // 'normal' | 'browse' | 'review'
scormState.session.credit; // 'credit' | 'no-credit'
scormState.session.entry; // 'ab-initio' | 'ab_initio' | 'resume' | ''

Methods

scormState.commit(); // force save to LMS
scormState.terminate(); // end session (called automatically on page unload)

Quiz & Test System

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.

Question types

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
});

The Question interface

Both 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

What defineTest() handles automatically

  1. Slide completion — calls useSlideCompletion() internally; marks the slide as passed (with score) or sets the score on failure
  2. Weighted scoringscore = (earned weight / total weight) * 100
  3. Pass thresholdisPassed when score >= passThreshold * 100
  4. Session restore — if the test was already passed in a previous session, pre-fills selectedAnswer from persisted attempts
  5. Course score — all tests automatically contribute to scormState.score.raw via the test registry (scaled to the course's minScoremaxScore range)

Course score aggregation

Every defineTest() call registers the test with a global testRegistry. The registry:

  • Averages all test scores into a single course score percentage
  • Scales that percentage to the course's minScoremaxScore range and writes it to scormState.score.raw
  • Calls scormState.completion.setPassed() when all tests pass

No manual wiring needed. If your course has two quiz slides each with a defineTest(), the course score is the average of their two scores.

Full example

<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}

Creating a new question type

To add a new question type (e.g., fill-in-the-blank):

  1. Create src/_core/quiz/define-fill-in.svelte.ts
  2. Export a factory that returns an object conforming to Question
  3. Internally call scormState.store.recordInteraction({ type: "fill-in", ... })
  4. Re-export from src/_core/quiz/index.ts

The new type works with defineTest() immediately — no changes to the test or scoring system.


Course Score vs Slide Score

These are two separate concepts that serve different purposes.

Course score (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)
  • Range defined by minScore / maxScore in defineCourse()
  • If you use defineTest(), the course score is set automatically via the test registry (see Quiz & Test System)
  • You can also set it manually for custom grading logic
  • The LMS uses this alongside masteryScore to determine pass/fail
scormState.score.raw = 85;
// SCORM 1.2:  raw = 85
// SCORM 2004: raw = 85, scaled = 0.85 (auto)

Slide score (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
  • Always 0–100 range (fixed)
  • Stored per-slide via cmi.objectives
  • Not automatically aggregated into the course score
  • Used for per-slide tracking, not LMS gradebook reporting

When to use which

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

Course Completion vs Slide Completion

These are also two separate concepts.

Course completion (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
  • You call these yourself when your course logic decides the learner is done
  • The LMS uses this to determine if the learner has finished

Slide completion (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)
  • Managed automatically for 'auto' slides (passed on visit)
  • Managed via useSlideCompletion() for 'manual' slides
  • Not automatically aggregated into course completion

When to use which

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

Persistent Store

Key-value storage that persists in SCORM suspend_data.

const store = scormState.store;

String / Number / Boolean / Object

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.

Access All Variables

store.variables; // Record<string, unknown> — reactive

Interactions

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)
});

Reading History (current session only)

store.interactionHistory; // Record<string, RecordedInteraction[]>

// RecordedInteraction:
// { id, type, learnerResponse, correctResponse, result, weighting, timestamp }

Note: cmi.interactions are write-only on most SCORM 1.2 LMS. History is available within a session but not across sessions. Use objectives for cross-session state.


Objectives

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? }

How it works

  • When result === 'correct' -> objective status = 'passed'
  • When result === 'incorrect' -> objective status = 'failed'
  • A 'passed' objective is never downgraded to 'failed'
  • Objective IDs should use a q: prefix for quiz objectives: q:lessonId:slideId:questionId

Slide objectives

Slide 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.


Storage Modes

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

App Structure

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.)

Layouts

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 vs LMS Mode

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.

Top categories

Loading Svelte Themes