Home / Blog

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

Create an Animated Walking Character in Articulate Storyline And use JavaScript to make it move - Part 2

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 characterState variable and state-change triggers configured

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

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

  2. Position this "World" element on your slide so the left edge aligns with the slide's left edge

  3. Rename it to "World" in the Selection Pane

  4. Get its data-model-id the same way you got the character's ID

  5. Make 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

  1. Create hotspot objects (shapes, buttons, or images) and position them within your world

  2. Get each hotspot's data-model-id

  3. Create corresponding Storyline variables for each hotspot

  4. Add triggers in Storyline to respond when these variables change

Hotspot Configuration

Each hotspot in the CONFIG needs:

  • id – The data-model-id of 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

moveSpeedBase

2

Starting movement speed in pixels/frame

accelerationStep

0.5

Speed increase per frame while moving

maxSpeed

15

Maximum movement speed

scrollEdgeThreshold

20

Pixels from center before world scrolling activates

hotspotRadius

300

How close character must be to activate hotspot

debug

true

Enable/disable console logging


Storyline Setup

Variables Needed

  • characterState (Text) – Controls animation states

  • One 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-id is correct in CONFIG

  • Make sure the world element is wider than the stage

  • Check console for initialization errors

Hotspots not triggering:

  • Confirm each hotspot's data-model-id matches CONFIG

  • Check that Storyline variables exist with matching names

  • Open browser console (F12) to see distance calculations

  • Try increasing hotspotRadius in 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!