LittleJS Migration: Step-by-Step Implementation Guide
This is a practical, actionable guide to migrate Nordic Skating to LittleJS. Follow these steps in order.
Phase 0: Preparation (1-2 hours)
Step 0.1: Install LittleJS
cd /Users/pavel/code/nordic-skating
npm install littlejsengine
Step 0.2: Back up current code
cp src/main.ts src/main.ts.backup
cp index.html index.html.backup
Step 0.3: Test POC (Optional but recommended)
cd LITTLEJS_POC
npm install
npm run dev
# Test in browser, then Ctrl+C
cd ..
Phase 1: Minimal Integration - Input + Game Loop (1-2 days)
Step 1.1: Create new entry point
Create src/main-littlejs.ts:
import * as THREE from 'three'
import {
engineInit,
engine,
inputGetState,
inputGetPressed,
inputGetAxis,
setThreeRenderer,
vec2,
colorWhite,
drawTextScreen
} from 'littlejsengine'
// Import all existing modules
import skateIcon from '../assets/skate-shoes.png'
import soundOffIcon from '../assets/sound-off.png'
import soundOnIcon from '../assets/sound-on.png'
import tiltOffIcon from '../assets/tilt-off.png'
import tiltOnIcon from '../assets/tilt-on.png'
import { IceSound } from './audio/iceSound.js'
import { Input as OldInput } from './input.js'
import { Drifts } from './render/drifts.js'
import { Ice } from './render/ice.js'
import { Marks } from './render/marks.js'
import { Shores } from './render/shores.js'
import { LOOKS, Sky, TIME_NAMES, type TimeName } from './render/sky.js'
import { Snow } from './render/snow.js'
import { WEATHER, WEATHERS, type Weather, anyLake, loadSettings, saveSettings, timeOf, weatherOf, windOf } from './settings.js'
import { BREAKING_THICKNESS, CRACKING_THICKNESS } from './ice/strength.js'
import { PACES, PACE_NAMES, type Pace, UNDER_SECONDS, createSkater, pushSeconds, sincePush, stepSkater } from './skater.js'
import { createLake } from './world/lake.js'
// Make THREE available globally
if (typeof window !== 'undefined') {
(window as any).THREE = THREE
}
class NordicSkating {
// All existing fields from main.ts
private lake: any
private skater: any
private scene: THREE.Scene
private camera: THREE.PerspectiveCamera
private renderer: THREE.WebGLRenderer
// ... etc (copy all from main.ts)
constructor() {
// Get canvas
const canvas = document.querySelector<HTMLCanvasElement>('#scene')!
// Configure LittleJS engine
engine.canvas = canvas
engine.canvasSize = vec2(canvas.width, canvas.height)
// Initialize Three.js renderer
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
// Set Three.js renderer for LittleJS
setThreeRenderer(THREE)
// Now run existing initialization code
this.initializeGame()
// Initialize LittleJS
engineInit(
() => this.gameInit(),
() => this.gameUpdate(),
() => this.gameUpdatePost(),
() => this.gameRender(),
() => this.gameRenderPost()
)
}
private initializeGame(): void {
// Copy all initialization code from main.ts
// But replace custom input with LittleJS input
// ... (all existing init code)
// REPLACE: Remove old Input initialization
// OLD: const input = new Input(canvas)
// NEW: Use LittleJS input system directly
}
gameInit(): void {
// Initialization that needs to happen in LittleJS context
}
gameUpdate(): void {
// NEW: Handle input using LittleJS
this.handleLittleJSInput()
// EXISTING: All update logic from main.ts animation loop
const passed = engine.timing.lastTimeStep
const dt = this.waiting() ? 0 : passed
// ... rest of update logic
}
private handleLittleJSInput(): void {
// Map LittleJS input to existing input system
// Steering
let steer = 0
steer += inputGetState('arrowLeft') ? -1 : 0
steer += inputGetState('arrowRight') ? 1 : 0
steer += inputGetState('a') ? -1 : 0
steer += inputGetState('d') ? 1 : 0
steer += inputGetAxis('mouseX') * 0.5
// Store for use in existing code
this.currentSteer = steer
// Button presses
if (inputGetPressed('m')) {
this.toggleMute()
}
if (inputGetPressed('r')) {
this.randomize()
}
if (inputGetPressed('arrowUp') || inputGetPressed('w')) {
this.stepPace(1, true)
}
if (inputGetPressed('arrowDown') || inputGetPressed('s')) {
this.stepPace(-1, true)
}
// Gamepad
for (const button of this.pollLittleJSPads()) {
// ... handle gamepad buttons
}
// Start sound on any input
if (Math.abs(steer) > 0 ||
inputGetPressed('m') ||
inputGetPressed('r') ||
inputGetPressed('arrowUp') ||
inputGetPressed('arrowDown')) {
this.startSound()
}
}
private pollLittleJSPads(): number[] {
// Implement gamepad polling using LittleJS
const pressed: number[] = []
const down = new Set<number>()
for (const pad of navigator.getGamepads?.() ?? []) {
if (!pad) continue
// Check buttons
pad.buttons.forEach((button, n) => {
if (button.pressed) {
down.add(n)
if (!this.padDown.has(n)) pressed.push(n)
}
})
}
this.padDown = down
return pressed
}
gameUpdatePost(): void {
// Post-update logic
}
gameRender(): void {
// Render the Three.js scene
this.renderer.render(this.scene, this.camera)
}
gameRenderPost(): void {
// Draw HUD using LittleJS (optional)
// Can keep existing DOM-based UI or use LittleJS
}
}
// Initialize when ready
new NordicSkating()
Step 1.2: Update index.html
Replace the script tag:
<!-- OLD -->
<script type="module" src="/src/main.ts"></script>
<!-- NEW -->
<script type="module" src="/src/main-littlejs.ts"></script>
Step 1.3: Update vite.config.ts (if needed)
Ensure LittleJS can be resolved:
import { defineConfig } from 'vite'
export default defineConfig({
resolve: {
alias: {
littlejsengine: 'littlejsengine/dist/littlejs.esm.js'
}
}
})
Step 1.4: Test basic functionality
npm run dev
Test checklist: - [ ] Page loads without errors - [ ] Keyboard steering works - [ ] Mouse/touch steering works - [ ] Gamepad works (if available) - [ ] Pace changes work - [ ] Random lake works - [ ] UI still functions - [ ] Audio works
Phase 2: Input System Migration (1 day)
Step 2.1: Update tilt steering
Replace custom device orientation with LittleJS:
// OLD: Custom tilt handling in Input class
// NEW: Use LittleJS device orientation
// In gameUpdate():
const tiltX = inputGetAxis('deviceOrientationGamma')
const tiltY = inputGetAxis('deviceOrientationBeta')
// Process tilt...
Step 2.2: Remove old Input class usage
Gradually replace all calls to the old Input class with LittleJS input functions.
Step 2.3: Test on mobile
- Test on iOS and Android devices
- Verify tilt steering works
- Check touch input
Phase 3: Game Loop Migration (1 day)
Step 3.1: Remove custom animation loop
Remove the renderer.setAnimationLoop() call from main.ts and replace with LittleJS game loop.
Step 3.2: Convert timing to use delta time
Replace all clock.getDelta() with engine.timing.lastTimeStep:
// OLD
const passed = clock.getDelta()
// NEW
const passed = engine.timing.lastTimeStep
Step 3.3: Test frame rate independence
- Verify physics work the same at different frame rates
- Test on low-end devices
Phase 4: Camera and Rendering (2-3 days)
Step 4.1: Keep existing rendering code
The Three.js rendering code can remain largely unchanged.
Step 4.2: Integrate with LittleJS render loop
Ensure the Three.js renderer is properly integrated with LittleJS.
Step 4.3: Add LittleJS HUD (optional)
Replace DOM-based HUD with LittleJS text rendering:
gameRenderPost(): void {
const x = 20
let y = 40
drawTextScreen(
`Speed: ${Math.round(this.skater.speed * 3.6)} km/h`,
vec2(x, y),
24,
colorWhite
)
// ... more HUD elements
}
Phase 5: Audio Integration (1-2 days)
Step 5.1: Keep existing audio system
The custom Web Audio code is excellent and should be kept.
Step 5.2: Trigger audio from game loop
// In gameUpdate():
if (this.sounding() && this.skater.under === 0) {
const h = this.lake.thickness(this.skater.x, this.skater.s)
const lying = this.lake.snow(this.skater.x, this.skater.s)
if (Math.floor(this.skater.stride) !== this.strides) {
this.sound?.strike(h, undefined, (0.75 + 0.5 * Math.random()) * (1 - 0.9 * lying))
}
this.sound?.glide(h, Math.exp(-sincePush(this.skater) / 0.5), undefined, lying)
}
Step 5.3: Handle audio context
Use LittleJS to manage audio context state if needed, or keep existing approach.
Phase 6: UI Migration (2-3 days)
Step 6.1: Keep DOM-based UI (easiest)
The existing dialog-based UI works well. Just trigger it from LittleJS input.
Step 6.2: Or migrate to LittleJS UI (optional)
Use LittleJS UI system for buttons and dialogs:
// Create buttons
const soundButton = new Button(
vec2(100, 50),
vec2(200, 60),
'Sound',
() => this.toggleMute()
)
Phase 7: Polish and Testing (3-5 days)
Step 7.1: Full regression testing
Test every feature against the current implementation.
Step 7.2: Performance testing
- Compare FPS with current implementation
- Check memory usage
- Test on all target devices
Step 7.3: User testing
- Get feedback on controls feel
- Verify audio quality
- Check visual quality
Step 7.4: Bug fixing
Fix any issues discovered during testing.
Phase 8: Cleanup (1-2 days)
Step 8.1: Remove deprecated code
- Remove old Input class
- Remove custom animation loop code
- Remove any other replaced systems
Step 8.2: Update documentation
- Update README
- Update build instructions
- Document any changes
Step 8.3: Final testing
One more round of full testing.
Migration Checklist
Use this checklist to track progress:
Phase 1: Setup
- [ ] LittleJS installed
- [ ] New entry point created
- [ ] index.html updated
- [ ] Vite config updated (if needed)
- [ ] Basic page loads
Phase 2: Input
- [ ] Keyboard input works
- [ ] Mouse/touch input works
- [ ] Gamepad input works
- [ ] Tilt input works
- [ ] All button mappings correct
Phase 3: Game Loop
- [ ] Custom animation loop removed
- [ ] Delta time integrated
- [ ] Physics work correctly
- [ ] Frame rate independent
Phase 4: Rendering
- [ ] Three.js rendering works
- [ ] Camera follows correctly
- [ ] All visuals correct
- [ ] HUD works (DOM or LittleJS)
Phase 5: Audio
- [ ] Audio triggers correctly
- [ ] Audio context handled
- [ ] All sounds work
- [ ] Volume/mute works
Phase 6: UI
- [ ] Settings dialog works
- [ ] About dialog works
- [ ] Buttons work
- [ ] Responsive design maintained
Phase 7: Testing
- [ ] All features tested
- [ ] Performance acceptable
- [ ] Mobile devices tested
- [ ] No regressions
Phase 8: Cleanup
- [ ] Deprecated code removed
- [ ] Documentation updated
- [ ] Final testing complete
Rollback Plan
If you need to rollback at any point:
# Restore original files
cp src/main.ts.backup src/main.ts
cp index.html.backup index.html
# Remove LittleJS
npm uninstall littlejsengine
# Reinstall original dependencies
npm install
Tips and Tricks
1. Debugging Input
If input isn't working:
// Add debug logging
console.log('Keyboard states:', {
left: inputGetState('arrowLeft'),
right: inputGetState('arrowRight'),
a: inputGetState('a'),
d: inputGetState('d')
})
2. Checking Delta Time
console.log('Delta time:', engine.timing.lastTimeStep)
console.log('FPS:', engine.timing.fps)
3. Device Orientation Debug
console.log('Orientation:', {
gamma: inputGetAxis('deviceOrientationGamma'),
beta: inputGetAxis('deviceOrientationBeta')
})
4. Canvas Issues
If canvas isn't rendering:
- Check that engine.canvas is set correctly
- Verify Three.js renderer is using the same canvas
- Ensure setThreeRenderer(THREE) is called
5. Performance Issues
- Check for double rendering (Three.js + LittleJS)
- Verify only one render loop is running
- Use Chrome DevTools to profile
Common Pitfalls
1. Input Names Don't Match
LittleJS uses specific key names. Check the LittleJS Input Documentation.
Solution: Use the exact key names LittleJS expects:
- arrowLeft, arrowRight, arrowUp, arrowDown
- a, d, w, s
- m, r
2. Canvas Not Found
Solution: Ensure the canvas element exists before initialization:
const canvas = document.getElementById('scene')
if (!canvas) {
console.error('Canvas not found!')
return
}
3. THREE Not Available
Solution: Make THREE globally available:
(window as any).THREE = THREE
4. Audio Context Issues
Solution: Audio must be triggered by user interaction. Use any input to start it:
if (anyInputDetected) {
if (audio.state === 'suspended') {
audio.resume()
}
}
5. Mobile Input Not Working
Solution: - Check for touch event handling - Ensure canvas has proper touch action settings - Test on actual device (simulators may not work)
Success Criteria Checklist
Before declaring the migration complete, verify:
Functionality
- [ ] All current features work exactly as before
- [ ] No features are broken or missing
- [ ] Controls feel the same or better
- [ ] Audio quality unchanged
- [ ] Visual quality unchanged
Performance
- [ ] Frame rate >= original implementation
- [ ] Memory usage <= original implementation
- [ ] Load time acceptable
- [ ] Works on all target devices
Code Quality
- [ ] Code is cleaner and more maintainable
- [ ] No unnecessary code left behind
- [ ] Documentation updated
- [ ] TypeScript types correct
User Experience
- [ ] No noticeable difference for users
- [ ] Mobile experience improved (if applicable)
- [ ] All input methods work
- [ ] No new bugs introduced
Final Notes
This migration is low-risk and high-reward due to:
- Three.js plugin means rendering code stays the same
- Incremental approach allows validating each step
- Minimal integration provides immediate benefits
- Well-documented API with good examples
- Active community for support
Estimated time for full migration: 17-26 days (part-time) or 8-13 days (full-time)
Estimated time for minimal integration: 3-5 days (part-time) or 1-2 days (full-time)
Recommendation: Start with minimal integration to gain immediate benefits, then proceed with full migration if successful.