Create an Animated Walking Character in Articulate Storyline And use JavaScript to make it move - Part 2
By Nathan Stoker · January 7, 2026 · 8 min read

Adding Scrolling Backgrounds and Interactive Hotspots
In Part 1, we built a smooth, keyboard-controlled animated character with proper state management. Now we're taking it further with a scrolling world and interactive hotspots that respond when your character gets close.
Ready to dive in? Check out the demo and download the file in the header to follow along.
What You'll Add in Part 2
A scrolling world/background that moves as your character explores
Edge detection that stops the character at world boundaries
Interactive hotspots that trigger Storyline variables when approached
Automatic hotspot reset when the character moves away
Proper cleanup to prevent memory leaks between slides
Prerequisites
Before starting Part 2, make sure you have:
A working character from Part 1 with idle and walking states
The
characterStatevariable and state-change triggers configuredGSAP library loaded in your project
The Refactored Architecture
Before diving into new features, we've improved the code structure from Part 1. The refactored version uses:
A centralized CONFIG object for all settings—no more hunting for magic numbers
A state object to track movement, keys held, and cleanup functions
Helper utilities to reduce repetition and improve readability
requestAnimationFrame instead of setInterval for smoother animation
GSAP's quickTo() for better performance on frequent updates
Proper event listener cleanup to prevent memory leaks
The Configuration Object
All customizable settings live in one place at the top of the script:
const CONFIG = {
// Movement settings
moveSpeedBase: 2,
accelerationStep: 0.5,
maxSpeed: 15,
scrollEdgeThreshold: 20,
// Hotspot detection
hotspotRadius: 300,
// Timing
layoutRetryDelay: 50,
layoutMaxRetries: 30,
hotspotRetryDelay: 50,
hotspotMaxRetries: 50,
moveIntervalMs: 16, // ~60fps
// Debug mode (set to false for production)
debug: true,
// Element IDs
characterId: '5mGJNQeqNPN',
worldId: '5c67Fsb3C3T',
// Hotspot definitions
hotspots: [
{
id: '66ZE09d2GoS',
value: true,
resetValue: false,
resetOnStateChange: true
},
{
id: '6kxGFGW23zt',
value: true,
resetValue: false,
resetOnStateChange: true
},
{
id: '6UnfboVBBcj',
value: true,
resetValue: false,
resetOnStateChange: true
}
]
};
This makes maintenance much easier—need to adjust the hotspot detection range? Change hotspotRadius. Want faster acceleration? Tweak accelerationStep. Deploying to production? Set debug: false.
The Scrolling World System
How It Works
The scrolling system uses a classic side-scroller technique: when the character reaches a "scroll zone" near the screen edge, instead of moving the character further, we move the entire world in the opposite direction.
Key concepts:
The World Container – A wide image or group containing your entire explorable environment
Scroll Zones – Invisible boundaries near the screen edges where scrolling activates
Edge Detection – Prevents the character from walking past the world boundaries
Dynamic Switching – The system intelligently decides whether to move the character or scroll the world
Set Up the World Element in Storyline
Create a wide image or grouped object that represents your explorable world (e.g., 3000px wide for a world that's about 3x the screen width)
Position this "World" element on your slide so the left edge aligns with the slide's left edge
Rename it to "World" in the Selection Pane
Get its
data-model-idthe same way you got the character's IDMake sure the character appears visually in front of the world element
Movement Handler with Scroll Zones
The refactored movement system uses gsap.quickTo() for better performance when updating positions every frame:
function createMovementHandler(stageWidth, charWidth, worldWidth, centerX) {
const mapMinLeft = stageWidth - worldWidth;
const mapMaxLeft = 0;
// Use quickTo for better performance
const moveWorld = gsap.quickTo(world, 'left', { duration: 0.05 });
const moveCharacter = gsap.quickTo(character, 'left', { duration: 0.05 });
return function handleMove(direction) {
const charLeft = getCharacterLeft();
const worldLeft = getWorldLeft();
const visualX = getCharacterWorldX();
const atLeftEdge = visualX <= 0;
const atRightEdge = visualX + charWidth >= worldWidth;
// Stop at world boundaries
if (atLeftEdge && direction === 'left') return;
if (atRightEdge && direction === 'right') return;
// Update animation state if direction changed
if (!state.isMoving || direction !== state.lastDirection) {
const walkState = direction === 'left' ? 'walkLeft' : 'walkRight';
setStorylineVar('characterState', walkState);
resetHotspots();
state.lastDirection = direction;
state.isMoving = true;
}
// Determine if we're in the scroll zone
const offsetFromCenter = charLeft - centerX;
const canScrollLeft = direction === 'left' && worldLeft < mapMaxLeft && !atLeftEdge;
const canScrollRight = direction === 'right' && worldLeft > mapMinLeft && !atRightEdge;
const inScrollZone =
(direction === 'left' && offsetFromCenter < -CONFIG.scrollEdgeThreshold && canScrollLeft) ||
(direction === 'right' && offsetFromCenter > CONFIG.scrollEdgeThreshold && canScrollRight);
// Move world or character
if (inScrollZone) {
const newWorldLeft = direction === 'left'
? Math.min(mapMaxLeft, worldLeft + state.currentSpeed)
: Math.max(mapMinLeft, worldLeft - state.currentSpeed);
moveWorld(newWorldLeft);
} else {
const newCharLeft = charLeft + (direction === 'left' ? -state.currentSpeed : state.currentSpeed);
moveCharacter(newCharLeft);
}
// Accelerate
state.currentSpeed = Math.min(CONFIG.maxSpeed, state.currentSpeed + CONFIG.accelerationStep);
};
}
Using requestAnimationFrame
Instead of setInterval, we use requestAnimationFrame for smoother animation that automatically pauses when the tab is hidden:
function startMoveLoop(direction, handleMove) {
if (state.moveAnimationId) return;
let lastTime = 0;
function loop(timestamp) {
if (!state.keysHeld.has(`arrow${direction}`)) {
stopMove();
return;
}
// Throttle to ~60fps
if (timestamp - lastTime >= CONFIG.moveIntervalMs) {
handleMove(direction);
lastTime = timestamp;
}
state.moveAnimationId = requestAnimationFrame(loop);
}
state.moveAnimationId = requestAnimationFrame(loop);
}
The Interactive Hotspot System
How Hotspots Work
Hotspots are objects placed within your world that trigger Storyline variables when the character gets close and the player presses the up arrow. This enables doors that open, NPCs that speak, items that get collected, and information points that reveal content.
Setting Up Hotspots in Storyline
Create hotspot objects (shapes, buttons, or images) and position them within your world
Get each hotspot's
data-model-idCreate corresponding Storyline variables for each hotspot
Add triggers in Storyline to respond when these variables change
Hotspot Configuration
Each hotspot in the CONFIG needs:
id – The
data-model-idof the hotspot element (also used as the Storyline variable name)value – Value to set when activated
resetValue – Value to reset to when character moves
resetOnStateChange – Whether to reset when character starts moving
hotspots: [
{
id: '66ZE09d2GoS',
value: true,
resetValue: false,
resetOnStateChange: true
}
]
Hotspot Detection
The detection function calculates distance from the character's center to each hotspot's center:
function getNearestHotspot() {
const worldRect = world.getBoundingClientRect();
const charRect = character.getBoundingClientRect();
const charCenterX = charRect.left - worldRect.left + charRect.width / 2;
const charCenterY = charRect.top - worldRect.top + charRect.height / 2;
for (const hotspot of hotspotRegistry) {
if (!hotspot.element) continue;
const spotRect = hotspot.element.getBoundingClientRect();
const spotCenterX = spotRect.left - worldRect.left + spotRect.width / 2;
const spotCenterY = spotRect.top - worldRect.top + spotRect.height / 2;
const dx = spotCenterX - charCenterX;
const dy = spotCenterY - charCenterY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= CONFIG.hotspotRadius) {
return hotspot;
}
}
return null;
}
Automatic Hotspot Reset
When the character starts moving, hotspots reset to their default values:
function resetHotspots() {
hotspotRegistry.forEach(hotspot => {
if (hotspot.resetOnStateChange) {
const resetValue = hotspot.resetValue ?? false;
setStorylineVar(hotspot.id, resetValue);
}
});
}
Improved Keyboard Handling
The original code had issues where releasing any key would stop movement. The refactored version tracks which keys are actually held:
const state = {
// ... other properties
keysHeld: new Set()
};
function onKeyDown(e) {
const key = e.key.toLowerCase();
// Ignore if already held (prevents key repeat issues)
if (state.keysHeld.has(key)) return;
state.keysHeld.add(key);
if (key === 'arrowleft') {
stopMove();
startMoveLoop('left', handleMove);
e.preventDefault();
return;
}
if (key === 'arrowright') {
stopMove();
startMoveLoop('right', handleMove);
e.preventDefault();
return;
}
if (key === 'arrowup') {
const nearby = getNearestHotspot();
if (nearby) {
setStorylineVar(nearby.id, nearby.value);
}
e.preventDefault();
}
}
function onKeyUp(e) {
const key = e.key.toLowerCase();
state.keysHeld.delete(key);
// Only stop if no movement keys are held
if (!state.keysHeld.has('arrowleft') && !state.keysHeld.has('arrowright')) {
stopMove();
} else if (state.keysHeld.has('arrowleft')) {
// Switch direction if other key still held
stopMove();
startMoveLoop('left', handleMove);
} else if (state.keysHeld.has('arrowright')) {
stopMove();
startMoveLoop('right', handleMove);
}
}
This fixes several issues: holding both arrow keys then releasing one now correctly continues in the remaining direction, and releasing unrelated keys doesn't stop movement.
Memory Management and Cleanup
Storyline can navigate between slides, and without proper cleanup, event listeners stack up and cause issues. The refactored code tracks all listeners and provides a cleanup function:
const state = {
// ... other properties
cleanupFns: []
};
function addEventListener(target, event, handler) {
target.addEventListener(event, handler);
state.cleanupFns.push(() => target.removeEventListener(event, handler));
}
function cleanup() {
stopMove();
state.cleanupFns.forEach(fn => fn());
state.cleanupFns = [];
delete window._scrollingBGCleanup;
}
// Expose for Storyline to call
window._scrollingBGCleanup = cleanup;
Add a slide exit trigger in Storyline:
Execute JavaScript: window._scrollingBGCleanup && window._scrollingBGCleanup();
Helper Utilities
The refactored code uses helper functions to reduce repetition:
function log(...args) {
if (CONFIG.debug) console.log(...args);
}
function getPlayer() {
return window.GetPlayer?.();
}
function setStorylineVar(name, value) {
try {
const player = getPlayer();
if (player?.SetVar) {
player.SetVar(name, value);
log(`✅ Set Storyline variable ${name} = ${value}`);
return true;
}
} catch (err) {
console.warn(`⚠️ Failed to set ${name}: ${err.message}`);
}
return false;
}
The setStorylineVar helper eliminates the repeated if (window.GetPlayer && typeof window.GetPlayer().SetVar === 'function') checks scattered throughout the original code.
The Complete JavaScript Code
Here's the full refactored script:
(function () {
try {
// ========================================
// 1. CONFIGURATION
// ========================================
const CONFIG = {
// Movement settings
moveSpeedBase: 2,
accelerationStep: 0.5,
maxSpeed: 15,
scrollEdgeThreshold: 20,
// Hotspot detection
hotspotRadius: 300,
// Timing
layoutRetryDelay: 50,
layoutMaxRetries: 30,
hotspotRetryDelay: 50,
hotspotMaxRetries: 50,
moveIntervalMs: 16, // ~60fps
// Debug mode (set to false for production)
debug: true,
// Element IDs
characterId: '5mGJNQeqNPN',
worldId: '5c67Fsb3C3T',
// Hotspot definitions
hotspots: [
{
id: '66ZE09d2GoS',
value: true,
resetValue: false,
resetOnStateChange: true
},
{
id: '6kxGFGW23zt',
value: true,
resetValue: false,
resetOnStateChange: true
},
{
id: '6UnfboVBBcj',
value: true,
resetValue: false,
resetOnStateChange: true
}
]
};
// ========================================
// 2. STATE
// ========================================
const state = {
currentSpeed: CONFIG.moveSpeedBase,
moveAnimationId: null,
lastDirection: 'right',
isMoving: false,
keysHeld: new Set(),
cleanupFns: []
};
// ========================================
// 3. UTILITIES
// ========================================
function log(...args) {
if (CONFIG.debug) console.log(...args);
}
function warn(...args) {
console.warn(...args);
}
function error(...args) {
console.error(...args);
}
function getPlayer() {
return window.GetPlayer?.();
}
function setStorylineVar(name, value) {
try {
const player = getPlayer();
if (player?.SetVar) {
player.SetVar(name, value);
log(`✅ Set Storyline variable ${name} = ${value}`);
return true;
}
warn(`⚠️ Storyline player not available for ${name}`);
} catch (err) {
warn(`⚠️ Failed to set ${name}: ${err.message}`);
}
return false;
}
function addEventListener(target, event, handler) {
target.addEventListener(event, handler);
state.cleanupFns.push(() => target.removeEventListener(event, handler));
}
// ========================================
// 4. ELEMENT DISCOVERY
// ========================================
const character = document.querySelector(`[data-model-id="${CONFIG.characterId}"]`);
const world = document.querySelector(`[data-model-id="${CONFIG.worldId}"]`);
if (!character) {
error(`❌ Character element not found (data-model-id='${CONFIG.characterId}')`);
return;
}
if (!world) {
error(`❌ World element not found (data-model-id='${CONFIG.worldId}')`);
return;
}
if (!window.gsap) {
error("❌ GSAP library not found. Include gsap.min.js in your project.");
return;
}
// ========================================
// 5. HOTSPOT REGISTRY
// ========================================
const hotspotRegistry = CONFIG.hotspots.map(hotspot => ({
...hotspot,
element: null,
x: 0,
y: 0
}));
// ========================================
// 6. INITIAL CSS
// ========================================
world.style.position = 'absolute';
character.style.position = 'absolute';
character.style.visibility = 'hidden';
character.style.left = '50%';
// ========================================
// 7. STAGE CONTAINER DISCOVERY
// ========================================
function findStageContainer() {
let container = world.parentElement;
let depth = 0;
while (container && depth < 15) {
if (container.offsetWidth > 0) {
return container;
}
container = container.parentElement;
depth++;
}
return null;
}
const stageContainer = findStageContainer();
if (!stageContainer) {
error("❌ No valid stage container found.");
return;
}
// ========================================
// 8. HOTSPOT FUNCTIONS
// ========================================
function resetHotspots() {
hotspotRegistry.forEach(hotspot => {
if (hotspot.resetOnStateChange) {
const resetValue = hotspot.resetValue ?? false;
setStorylineVar(hotspot.id, resetValue);
} else {
log(`ℹ️ Skipped reset for ${hotspot.id}`);
}
});
}
function initializeHotspots() {
return new Promise(resolve => {
let tries = 0;
function checkHotspots() {
let allFound = true;
for (const hotspot of hotspotRegistry) {
if (!hotspot.element) {
const el = document.querySelector(`[data-model-id="${hotspot.id}"]`);
if (el) {
hotspot.element = el;
log(`✅ Hotspot ${hotspot.id} found`);
} else {
allFound = false;
}
}
}
if (allFound || tries++ >= CONFIG.hotspotMaxRetries) {
if (!allFound) warn('⚠️ Some hotspots not found after retries');
resolve();
} else {
setTimeout(checkHotspots, CONFIG.hotspotRetryDelay);
}
}
checkHotspots();
});
}
function positionHotspots() {
const worldRect = world.getBoundingClientRect();
for (const hotspot of hotspotRegistry) {
if (!hotspot.element) {
warn(`⚠️ Hotspot ${hotspot.id} has no element, skipping`);
continue;
}
const rect = hotspot.element.getBoundingClientRect();
hotspot.x = rect.left - worldRect.left;
hotspot.y = rect.top - worldRect.top;
Object.assign(hotspot.element.style, {
position: 'absolute',
left: `${hotspot.x}px`,
top: `${hotspot.y}px`,
margin: '0',
transform: 'none'
});
world.appendChild(hotspot.element);
log(`✅ Hotspot ${hotspot.id} positioned at (${hotspot.x}, ${hotspot.y})`);
}
}
function getNearestHotspot() {
const worldRect = world.getBoundingClientRect();
const charRect = character.getBoundingClientRect();
const charCenterX = charRect.left - worldRect.left + charRect.width / 2;
const charCenterY = charRect.top - worldRect.top + charRect.height / 2;
for (const hotspot of hotspotRegistry) {
if (!hotspot.element) continue;
const spotRect = hotspot.element.getBoundingClientRect();
const spotCenterX = spotRect.left - worldRect.left + spotRect.width / 2;
const spotCenterY = spotRect.top - worldRect.top + spotRect.height / 2;
const dx = spotCenterX - charCenterX;
const dy = spotCenterY - charCenterY;
const distance = Math.sqrt(dx * dx + dy * dy);
log(`Hotspot ${hotspot.id}: distance=${distance.toFixed(2)}`);
if (distance <= CONFIG.hotspotRadius) {
return hotspot;
}
}
return null;
}
// ========================================
// 9. LAYOUT WAITING
// ========================================
function waitForLayout() {
return new Promise((resolve, reject) => {
let tries = 0;
function check() {
const layoutReady = character.offsetWidth > 0 && stageContainer.offsetWidth > 0;
const playerReady = getPlayer()?.SetVar;
if (layoutReady && playerReady) {
log('✅ Layout and Storyline player ready');
resolve();
} else if (tries++ >= CONFIG.layoutMaxRetries) {
if (!layoutReady) {
reject(new Error('Layout never stabilized'));
} else {
warn('⚠️ Storyline player not found, continuing anyway');
resolve();
}
} else {
log(`⏳ Waiting... (attempt ${tries})`);
setTimeout(check, CONFIG.layoutRetryDelay);
}
}
check();
});
}
// ========================================
// 10. MOVEMENT SYSTEM
// ========================================
function getWorldLeft() {
return parseFloat(world.style.left) || 0;
}
function getCharacterLeft() {
return parseFloat(character.style.left) || 0;
}
function getCharacterWorldX() {
const worldRect = world.getBoundingClientRect();
const charRect = character.getBoundingClientRect();
return charRect.left - worldRect.left;
}
function createMovementHandler(stageWidth, charWidth, worldWidth, centerX) {
const mapMinLeft = stageWidth - worldWidth;
const mapMaxLeft = 0;
// Use quickTo for better performance
const moveWorld = gsap.quickTo(world, 'left', { duration: 0.05 });
const moveCharacter = gsap.quickTo(character, 'left', { duration: 0.05 });
return function handleMove(direction) {
const charLeft = getCharacterLeft();
const worldLeft = getWorldLeft();
const visualX = getCharacterWorldX();
const atLeftEdge = visualX <= 0;
const atRightEdge = visualX + charWidth >= worldWidth;
// Check for edge stops
if (atLeftEdge && direction === 'left') {
log('Stopped at left edge');
return;
}
if (atRightEdge && direction === 'right') {
log('Stopped at right edge');
return;
}
// Update animation state if direction changed
if (!state.isMoving || direction !== state.lastDirection) {
const walkState = direction === 'left' ? 'walkLeft' : 'walkRight';
setStorylineVar('characterState', walkState);
resetHotspots();
state.lastDirection = direction;
state.isMoving = true;
}
// Determine scroll zone
const offsetFromCenter = charLeft - centerX;
const canScrollLeft = direction === 'left' && worldLeft < mapMaxLeft && !atLeftEdge;
const canScrollRight = direction === 'right' && worldLeft > mapMinLeft && !atRightEdge;
const inScrollZone =
(direction === 'left' && offsetFromCenter < -CONFIG.scrollEdgeThreshold && canScrollLeft) ||
(direction === 'right' && offsetFromCenter > CONFIG.scrollEdgeThreshold && canScrollRight);
// Move world or character
if (inScrollZone) {
const newWorldLeft = direction === 'left'
? Math.min(mapMaxLeft, worldLeft + state.currentSpeed)
: Math.max(mapMinLeft, worldLeft - state.currentSpeed);
moveWorld(newWorldLeft);
} else {
const newCharLeft = charLeft + (direction === 'left' ? -state.currentSpeed : state.currentSpeed);
moveCharacter(newCharLeft);
}
// Accelerate
state.currentSpeed = Math.min(CONFIG.maxSpeed, state.currentSpeed + CONFIG.accelerationStep);
};
}
function stopMove() {
if (state.moveAnimationId) {
cancelAnimationFrame(state.moveAnimationId);
state.moveAnimationId = null;
state.currentSpeed = CONFIG.moveSpeedBase;
if (state.isMoving) {
const idleState = state.lastDirection === 'left' ? 'idleLeft' : 'idleRight';
setStorylineVar('characterState', idleState);
resetHotspots();
state.isMoving = false;
}
}
}
function startMoveLoop(direction, handleMove) {
if (state.moveAnimationId) return;
let lastTime = 0;
function loop(timestamp) {
if (!state.keysHeld.has(`arrow${direction}`)) {
stopMove();
return;
}
// Throttle to ~60fps
if (timestamp - lastTime >= CONFIG.moveIntervalMs) {
handleMove(direction);
lastTime = timestamp;
}
state.moveAnimationId = requestAnimationFrame(loop);
}
state.moveAnimationId = requestAnimationFrame(loop);
}
// ========================================
// 11. CLEANUP
// ========================================
function cleanup() {
stopMove();
state.cleanupFns.forEach(fn => fn());
state.cleanupFns = [];
delete window._scrollingBGCleanup;
log('🧹 Cleanup complete');
}
// Expose cleanup for Storyline to call on slide exit
window._scrollingBGCleanup = cleanup;
// ========================================
// 12. MAIN INITIALIZATION
// ========================================
async function initialize() {
await waitForLayout();
const stageWidth = stageContainer.offsetWidth;
const charWidth = character.offsetWidth;
const worldWidth = world.offsetWidth;
const centerX = stageWidth / 2 - charWidth / 2;
// Position character
gsap.set(character, { left: centerX });
character.style.visibility = 'visible';
log(`✅ Character positioned at ${centerX}px`);
// Set initial state
setStorylineVar('characterState', 'idleRight');
resetHotspots();
// Initialize hotspots
await initializeHotspots();
positionHotspots();
// Create movement handler
const handleMove = createMovementHandler(stageWidth, charWidth, worldWidth, centerX);
// ========================================
// 13. INPUT HANDLERS
// ========================================
function onKeyDown(e) {
const key = e.key.toLowerCase();
// Ignore if already held
if (state.keysHeld.has(key)) return;
state.keysHeld.add(key);
// Movement keys
if (key === 'arrowleft') {
stopMove(); // Stop any existing movement first
startMoveLoop('left', handleMove);
e.preventDefault();
return;
}
if (key === 'arrowright') {
stopMove();
startMoveLoop('right', handleMove);
e.preventDefault();
return;
}
// Interaction key
if (key === 'arrowup') {
log('Checking for nearby hotspot...');
const nearby = getNearestHotspot();
if (nearby) {
log(`✅ Found hotspot: ${nearby.id}`);
setStorylineVar(nearby.id, nearby.value);
} else {
log('No hotspot nearby');
}
e.preventDefault();
}
}
function onKeyUp(e) {
const key = e.key.toLowerCase();
state.keysHeld.delete(key);
// Only stop if no movement keys are held
if (!state.keysHeld.has('arrowleft') && !state.keysHeld.has('arrowright')) {
stopMove();
} else if (state.keysHeld.has('arrowleft')) {
// Switch direction if other key still held
stopMove();
startMoveLoop('left', handleMove);
} else if (state.keysHeld.has('arrowright')) {
stopMove();
startMoveLoop('right', handleMove);
}
}
addEventListener(document, 'keydown', onKeyDown);
addEventListener(document, 'keyup', onKeyUp);
log('✅ Scrolling character controller initialized');
}
initialize().catch(err => {
error(`❌ Initialization failed: ${err.message}`);
error(err.stack);
});
} catch (err) {
console.error(`❌ Script failed: ${err.message}`);
console.error(err.stack);
}
})();
Configuration Quick Reference
Setting | Default | Description |
|---|---|---|
| 2 | Starting movement speed in pixels/frame |
| 0.5 | Speed increase per frame while moving |
| 15 | Maximum movement speed |
| 20 | Pixels from center before world scrolling activates |
| 300 | How close character must be to activate hotspot |
| true | Enable/disable console logging |
Storyline Setup
Variables Needed
characterState(Text) – Controls animation statesOne Boolean variable for each hotspot (named to match the hotspot's
data-model-id)
Triggers
For each hotspot variable, create a trigger:
When variable changes to True → Show Layer / Jump to Slide / etc.
Slide exit trigger (important for cleanup):
Execute JavaScript:
window._scrollingBGCleanup && window._scrollingBGCleanup();
Troubleshooting
World not scrolling:
Verify the world's
data-model-idis correct in CONFIGMake sure the world element is wider than the stage
Check console for initialization errors
Hotspots not triggering:
Confirm each hotspot's
data-model-idmatches CONFIGCheck that Storyline variables exist with matching names
Open browser console (F12) to see distance calculations
Try increasing
hotspotRadiusin CONFIG
Hotspots not scrolling with background:
The script automatically reparents hotspots to the world container
Check console for positioning logs during initialization
Character walks off the world:
Verify world element has a defined width in Storyline
Check console for edge detection logs
Movement feels janky after slide revisit:
Ensure the cleanup trigger is firing on slide exit
Check for multiple event listener warnings in console
What's Next?
With scrolling backgrounds and hotspots working, you can expand further:
Touch controls for mobile devices
Multiple characters or NPCs
Parallax scrolling with multiple background layers
Sound effects triggered by movement or hotspot activation
Inventory systems using hotspot values
Dialogue boxes that appear near activated hotspots
The foundation you've built—combining Storyline's state management with JavaScript's movement and interaction logic—can support complex interactive experiences. Happy building!