SCORM 1.2 Multi-SCO Support
This guide explains how to use the scorm-again LMS helper utilities to implement multi-SCO SCORM 1.2 course support in your LMS.
Overview
SCORM 1.2 does not define a sequencing specification - navigation between SCOs is entirely the LMS's responsibility. These utilities provide building blocks to help you implement:
- State Tracking: Track completion, scores, and time for each SCO
- Navigation: Determine next/previous SCO, handle exit actions
- Rollup: Calculate course-level scores, completion, and status
Installation
The utilities are included with scorm-again:
import {
ScoStateTracker,
Scorm12Sequencer,
CourseRollupCalculator,
ScoDefinition,
} from 'scorm-again/utilities/scorm12-lms-helpers';
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ Your LMS │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ State │ │ Sequencer │ │ Rollup │ │
│ │ Tracker │◄───│ │ │ Calculator │ │
│ └─────┬───────┘ └──────────────┘ └───────┬───────┘ │
│ │ │ │
│ │ Updates state on │ │
│ │ LMSCommit/LMSFinish │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Scorm12API │ │
│ └─────────────────────────────────────────────────────┘ │
│ ▲ │
│ │ postMessage / direct calls │
│ │ │
└────────┼───────────────────────────────────────────────────┘
│
┌────────┴───────────────────────────────────────────────────┐
│ SCO Content (iframe) │
└─────────────────────────────────────────────────────────────┘
Complete Implementation Example
Step 1: Define Your Course Structure
Parse your SCORM manifest to create SCO definitions:
// From your imsmanifest.xml parsing
const scoDefinitions: ScoDefinition[] = [
{
id: 'sco_intro',
title: 'Introduction',
launchUrl: 'content/intro/index.html',
masteryScore: 80,
sequenceIndex: 0,
},
{
id: 'sco_module1',
title: 'Module 1: Basics',
launchUrl: 'content/module1/index.html',
masteryScore: 80,
sequenceIndex: 1,
},
{
id: 'sco_module2',
title: 'Module 2: Advanced',
launchUrl: 'content/module2/index.html',
masteryScore: 80,
sequenceIndex: 2,
},
{
id: 'sco_assessment',
title: 'Final Assessment',
launchUrl: 'content/assessment/index.html',
masteryScore: 70,
maxTimeAllowed: '0001:00:00.00', // 1 hour
timeLimitAction: 'exit,message',
sequenceIndex: 3,
},
];
Step 2: Initialize the Utilities
import Scorm12API from 'scorm-again';
import {
ScoStateTracker,
Scorm12Sequencer,
CourseRollupCalculator,
} from 'scorm-again/utilities/scorm12-lms-helpers';
class MultiScoPlayer {
private api: Scorm12API;
private tracker: ScoStateTracker;
private sequencer: Scorm12Sequencer;
private calculator: CourseRollupCalculator;
private currentScoId: string | null = null;
constructor(scoDefinitions: ScoDefinition[]) {
// Initialize state tracker
this.tracker = new ScoStateTracker();
// Initialize sequencer with your prerequisite system
this.sequencer = new Scorm12Sequencer(
scoDefinitions,
this.tracker,
(sco, tracker) => this.checkPrerequisites(sco.id)
);
// Initialize rollup calculator
this.calculator = new CourseRollupCalculator(this.tracker, {
scoreMethod: 'average',
completionMethod: 'all',
statusMethod: 'score_threshold',
passingScore: 80,
});
// Initialize SCORM API
this.api = new Scorm12API({
autocommit: true,
autocommitSeconds: 30,
// Enable global preferences for multi-SCO
globalStudentPreferences: true,
});
// Set up event handlers
this.setupEventHandlers();
}
// Your existing prerequisite system
private checkPrerequisites(scoId: string): boolean {
// Implement your LMS's prerequisite logic here
// Return true if the SCO can be launched
return true;
}
private setupEventHandlers(): void {
// Update state on every commit
this.api.on('LMSCommit', (data) => {
if (this.currentScoId) {
this.tracker.updateFromCmiData(this.currentScoId, data.cmi);
this.onStateChanged();
}
});
// Handle SCO completion
this.api.on('LMSFinish', (data) => {
if (this.currentScoId) {
// Final state update
this.tracker.updateFromCmiData(this.currentScoId, data.cmi);
// Get navigation suggestion
const suggestion = this.sequencer.processExitAction(
this.currentScoId,
data.cmi.core?.exit || ''
);
this.handleExitSuggestion(suggestion);
}
});
// Listen for state changes
this.tracker.onStateChange((event) => {
console.log(`SCO ${event.scoId} changed:`, event.changedFields);
this.persistState();
});
}
private onStateChanged(): void {
// Recalculate course rollup
const rollup = this.calculator.calculate();
// Update UI
this.updateProgressUI(rollup);
// Check for course completion
if (this.calculator.isCourseComplete()) {
this.onCourseComplete(rollup);
}
}
private handleExitSuggestion(suggestion: NavigationSuggestion): void {
console.log('Navigation suggestion:', suggestion);
switch (suggestion.action) {
case 'continue':
if (suggestion.targetScoId) {
this.showContinuePrompt(suggestion.targetScoId);
}
break;
case 'suspend':
this.showSuspendMessage();
break;
case 'retry':
this.showRetryPrompt(suggestion.targetScoId!);
break;
case 'exit':
this.showCourseMenu();
break;
}
}
}
Step 3: Launching SCOs
class MultiScoPlayer {
// ... previous code ...
async launchSco(scoId: string): Promise<void> {
const sco = this.sequencer.getSco(scoId);
if (!sco) {
throw new Error(`SCO not found: ${scoId}`);
}
// Check availability (includes prerequisites)
if (!this.sequencer.isScoAvailable(scoId)) {
throw new Error(`SCO not available: ${scoId}`);
}
// Get existing state for resume
const existingState = this.tracker.getScoState(scoId);
// Configure API with SCO data
this.api.loadFromJSON({
cmi: {
core: {
student_id: this.studentId,
student_name: this.studentName,
lesson_status: existingState?.lessonStatus || 'not attempted',
lesson_location: existingState?.lessonLocation || '',
score: {
raw: existingState?.score.raw?.toString() || '',
min: existingState?.score.min?.toString() || '0',
max: existingState?.score.max?.toString() || '100',
},
credit: 'credit',
entry: existingState?.hasBeenLaunched ? 'resume' : 'ab-initio',
},
suspend_data: existingState?.suspendData || '',
student_data: {
mastery_score: sco.masteryScore?.toString() || '',
max_time_allowed: sco.maxTimeAllowed || '',
time_limit_action: sco.timeLimitAction || '',
},
},
});
// Mark as launched
this.tracker.markLaunched(scoId);
this.currentScoId = scoId;
// Launch in iframe
const iframe = document.getElementById('sco-frame') as HTMLIFrameElement;
iframe.src = sco.launchUrl;
}
launchNext(): void {
const nextSco = this.sequencer.getNextSco(this.currentScoId);
if (nextSco) {
this.launchSco(nextSco.id);
}
}
launchPrevious(): void {
if (this.currentScoId) {
const prevSco = this.sequencer.getPreviousSco(this.currentScoId);
if (prevSco) {
this.launchSco(prevSco.id);
}
}
}
launchFirst(): void {
const startSco = this.sequencer.getStartingSco();
if (startSco) {
this.launchSco(startSco.id);
}
}
}
Step 4: Building the Course Menu
class MultiScoPlayer {
// ... previous code ...
buildCourseMenu(): MenuItems[] {
const scos = this.sequencer.getAllScos();
const progress = this.sequencer.getProgress();
return scos.map(sco => {
const state = this.tracker.getScoState(sco.id);
const isAvailable = this.sequencer.isScoAvailable(sco.id);
return {
id: sco.id,
title: sco.title,
status: state?.lessonStatus || 'not attempted',
score: state?.score.raw,
isAvailable,
isCompleted: this.tracker.isCompleted(sco.id),
isPassed: this.tracker.isPassed(sco.id),
isCurrent: sco.id === this.currentScoId,
onClick: isAvailable ? () => this.launchSco(sco.id) : undefined,
};
});
}
updateProgressUI(rollup: CourseRollupResult): void {
// Update progress bar
const progressBar = document.getElementById('progress-bar');
progressBar.style.width = `${rollup.completionPercentage}%`;
progressBar.textContent = `${rollup.completedCount}/${rollup.totalCount} Complete`;
// Update score display
const scoreDisplay = document.getElementById('score-display');
if (rollup.score !== undefined) {
scoreDisplay.textContent = `Score: ${rollup.score.toFixed(1)}%`;
}
// Update status badge
const statusBadge = document.getElementById('status-badge');
statusBadge.className = `status-${rollup.status}`;
statusBadge.textContent = rollup.status;
// Update time spent
const timeDisplay = document.getElementById('time-display');
timeDisplay.textContent = scormTimeToHumanReadable(rollup.totalTime);
}
}
Step 5: Persisting State
class MultiScoPlayer {
// ... previous code ...
// Call this to save state to your database
persistState(): void {
const stateToSave = {
scoStates: this.tracker.exportState(),
courseRollup: this.calculator.calculate(),
lastScoId: this.currentScoId,
timestamp: new Date().toISOString(),
};
// Save to your LMS database
this.lmsService.saveLearnerProgress(this.learnerId, this.courseId, stateToSave);
}
// Call this when course is resumed
async restoreState(): Promise<void> {
const savedState = await this.lmsService.getLearnerProgress(
this.learnerId,
this.courseId
);
if (savedState?.scoStates) {
this.tracker.importState(savedState.scoStates);
}
}
}
Step 6: Handling Course Completion
class MultiScoPlayer {
// ... previous code ...
private onCourseComplete(rollup: CourseRollupResult): void {
console.log('Course complete!', rollup);
// Record completion in LMS
this.lmsService.recordCompletion({
learnerId: this.learnerId,
courseId: this.courseId,
status: rollup.status,
score: rollup.score,
totalTime: rollup.totalTime,
completedAt: new Date(),
});
// Show completion UI
this.showCompletionScreen(rollup);
// Trigger any webhooks or integrations
this.notifyCompletion(rollup);
}
}
Integrating Your Prerequisite System
Since your LMS already handles prerequisites, integrate it via the availability filter:
const sequencer = new Scorm12Sequencer(
scoDefinitions,
tracker,
(sco, tracker) => {
// Your existing prerequisite check
return myLms.prerequisites.evaluate(sco.id, tracker);
}
);
// Or update it later
sequencer.setAvailabilityFilter((sco, tracker) => {
// Complex prerequisite logic
const prereqExpression = getPrerequisiteExpression(sco.id);
return evaluatePrerequisites(prereqExpression, tracker);
});
Rollup Configuration Options
Configure how course-level statistics are calculated:
const calculator = new CourseRollupCalculator(tracker, {
// How to calculate course score
scoreMethod: 'average', // 'average' | 'weighted' | 'highest' | 'lowest' | 'sum' | 'last'
// For weighted scoring
weights: new Map([
['sco_intro', 0.1],
['sco_module1', 0.3],
['sco_module2', 0.3],
['sco_assessment', 0.3],
]),
// How to determine completion
completionMethod: 'all', // 'all' | 'any' | 'percentage'
completionThreshold: 80, // For 'percentage' method
// How to determine pass/fail
statusMethod: 'score_threshold', // 'all_passed' | 'any_passed' | 'score_threshold' | 'completion_only'
passingScore: 80,
// Which statuses count as "completed"
completedStatuses: ['passed', 'completed', 'failed'],
});
Time Utilities
Helper functions for SCORM time format:
import {
parseScormTime,
formatScormTime,
addScormTime,
scormTimeToHumanReadable,
hasExceededTimeLimit,
} from 'scorm-again/utilities/scorm12-lms-helpers';
// Parse SCORM time to seconds
const seconds = parseScormTime('0001:30:45.50'); // 5445.5
// Format seconds to SCORM time
const scormTime = formatScormTime(5445.5); // '0001:30:45.50'
// Add times together
const total = addScormTime('0001:00:00.00', '0000:30:00.00'); // '0001:30:00.00'
// Human-readable format
const readable = scormTimeToHumanReadable('0001:30:45.50'); // '1h 30m 45s'
// Check time limits
const exceeded = hasExceededTimeLimit(currentTime, maxTimeAllowed);
Event Handling
The state tracker emits events on state changes:
tracker.onStateChange((event) => {
console.log('SCO changed:', event.scoId);
console.log('Changed fields:', event.changedFields);
console.log('Previous state:', event.previousState);
console.log('Current state:', event.currentState);
// React to specific changes
if (event.changedFields.includes('lessonStatus')) {
if (event.currentState.lessonStatus === 'passed') {
showPassedAnimation(event.scoId);
}
}
});
Best Practices
- Always persist state after
LMSCommitandLMSFinishevents - Use global preferences for consistent learner settings across SCOs
- Handle suspend properly - preserve bookmark and suspend_data
- Validate prerequisites before allowing SCO launch
- Show clear progress - use rollup calculator for accurate stats
- Handle time limits - check
maxTimeAllowedand respond appropriately
Troubleshooting
SCO state not updating
Ensure you're calling tracker.updateFromCmiData() in your LMSCommit handler.
Scores not rolling up correctly
Check that SCOs are setting cmi.core.score.raw (and optionally min/max).
Navigation not working
Verify your prerequisite filter is returning true for available SCOs.
Time not accumulating
Make sure SCOs are setting cmi.core.session_time before LMSFinish.