LittleJS Proof of Concept for Nordic Skating
This document provides a working proof-of-concept implementation to validate the migration approach before committing to the full conversion.
Quick Start
To test the POC:
cd /Users/pavel/code/nordic-skating
npm install littlejsengine
cp src/main.ts src/main.ts.backup
cp LITTLEJS_POC/src/main-littlejs.ts src/
npm run dev
POC Structure
The POC demonstrates: 1. LittleJS engine initialization with Three.js 2. Basic input handling (keyboard, mouse) 3. Simple skater movement 4. Lake rendering (static for POC) 5. Integration with existing lake generation
Proof of Concept Implementation
1. Install LittleJS
npm install littlejsengine
2. Create the POC Entry Point
src/littlejs-poc.ts:
/**
* Proof of Concept: LittleJS + Three.js Integration for Nordic Skating
*
* This file demonstrates that LittleJS can work alongside Three.js
* for rendering while handling input, game loop, and state management.
*/
import * as THREE from 'three'
import {
engineInit,
engine,
inputGetState,
inputGetPressed,
vec2,
colorWhite,
drawTextScreen,
setThreeRenderer
} from 'littlejsengine'
import { createLake, type Lake, mulberry32 } from './world/lake.js'
import { createSkater, stepSkater, PACES, type Skater } from './skater.js'
import { BREAKING_THICKNESS, CRACKING_THICKNESS } from './ice/strength.js'
// Constants
const EYE = 1.5
const BASE_FOV = 65
const PITCH = 0.12
class NordicPOC {
private lake: Lake
private skater: Skater
private scene: THREE.Scene
private camera: THREE.PerspectiveCamera
private renderer: THREE.WebGLRenderer
private iceMesh: THREE.Mesh
private shoresGroup: THREE.Group
// Input state
private steerValue = 0
private muted = false
private seed = 12345
constructor() {
// Store reference for Three.js
window.THREE = THREE
// Initialize LittleJS with Three.js plugin
this.initEngine()
}
private initEngine(): void {
// Set up canvas
const canvas = document.getElementById('scene') as HTMLCanvasElement
if (!canvas) {
console.error('Canvas element not found')
return
}
// Configure engine to use our canvas
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)
// Create scene
this.scene = new THREE.Scene()
const haze = new THREE.Fog(0, 30, 600)
this.scene.fog = haze
this.scene.background = haze.color
// Create camera
this.camera = new THREE.PerspectiveCamera(70, 1, 0.1, 1500)
this.camera.rotation.order = 'YXZ'
// Initialize world
this.lake = createLake(this.seed, { impossibleIce: false })
this.skater = createSkater(this.lake, 0)
this.skater.pace = 'cruise'
// Create simple ice plane for POC
this.createIcePlane()
// Create simple shores for POC
this.shoresGroup = new THREE.Group()
this.scene.add(this.shoresGroup)
// Create simple sky
this.createSimpleSky()
// Handle resize
window.addEventListener('resize', this.onResize.bind(this))
this.onResize()
// Initialize LittleJS
engineInit(
() => this.gameInit(),
() => this.gameUpdate(),
() => this.gameUpdatePost(),
() => this.gameRender(),
() => this.gameRenderPost()
)
}
private createIcePlane(): void {
// Simple ice plane for POC
const geometry = new THREE.PlaneGeometry(1000, 1000, 100, 100)
const material = new THREE.MeshStandardMaterial({
color: 0x0b1a24,
roughness: 0.9,
metalness: 0.1
})
this.iceMesh = new THREE.Mesh(geometry, material)
this.iceMesh.rotation.x = -Math.PI / 2
this.scene.add(this.iceMesh)
}
private createSimpleSky(): void {
// Simple skybox for POC
const geometry = new THREE.SphereGeometry(500, 32, 32)
const material = new THREE.MeshBasicMaterial({
color: 0x87ceeb,
side: THREE.BackSide
})
const sky = new THREE.Mesh(geometry, material)
this.scene.add(sky)
}
private onResize(): void {
const canvas = engine.canvas
this.renderer.setSize(canvas.width, canvas.height, false)
this.camera.aspect = canvas.width / canvas.height
this.camera.updateProjectionMatrix()
}
gameInit(): void {
console.log('LittleJS + Nordic Skating POC initialized')
}
gameUpdate(): void {
// Handle input using LittleJS
this.handleInput()
// Update skater using existing physics
const dt = engine.timing.lastTimeStep
stepSkater(this.skater, this.lake, this.steerValue, dt)
}
private handleInput(): void {
// Keyboard steering
let steer = 0
steer += inputGetState('arrowLeft') ? -1 : 0
steer += inputGetState('arrowRight') ? 1 : 0
steer += inputGetState('a') ? -1 : 0
steer += inputGetState('d') ? 1 : 0
// Mouse/touch steering
steer += inputGetAxis('mouseX') * 0.5
// Gamepad steering
steer += inputGetAxis('gamepadLeftX')
this.steerValue = Math.max(-1, Math.min(1, steer))
// Other inputs
if (inputGetPressed('m')) {
this.muted = !this.muted
console.log(`Sound ${this.muted ? 'off' : 'on'}`)
}
if (inputGetPressed('r')) {
this.seed = Math.floor(Math.random() * 999999) + 1
this.lake = createLake(this.seed, { impossibleIce: false })
this.skater = createSkater(this.lake, 0)
this.skater.pace = 'cruise'
console.log(`New lake: ${this.seed}`)
}
// Pace control
if (inputGetPressed('arrowUp') || inputGetPressed('w')) {
const currentIndex = PACES[0].findIndex(p => p === this.skater.pace)
const nextIndex = (currentIndex + 1) % PACES[0].length
this.skater.pace = PACES[0][nextIndex]
}
if (inputGetPressed('arrowDown') || inputGetPressed('s')) {
const currentIndex = PACES[0].findIndex(p => p === this.skater.pace)
const prevIndex = (currentIndex - 1 + PACES[0].length) % PACES[0].length
this.skater.pace = PACES[0][prevIndex]
}
// Start sound on any input (browser requirement)
if (this.steerValue !== 0 ||
inputGetPressed('m') ||
inputGetPressed('r') ||
inputGetPressed('arrowUp') ||
inputGetPressed('arrowDown')) {
this.startSound()
}
}
private startSound(): void {
// POC: Just log for now
console.log('Sound would start here')
}
gameUpdatePost(): void {
// Post-update logic
}
gameRender(): void {
// Update camera based on skater
this.updateCamera()
// Render Three.js scene
this.renderer.render(this.scene, this.camera)
}
private updateCamera(): void {
// Calculate camera position based on skater state
const pushFor = 0.5 * PACES[this.skater.pace].strides
const push = this.skater.under === 0 &&
((Date.now() / 1000) % this.skater.stride) < pushFor
? Math.sin((Math.PI * ((Date.now() / 1000) % this.skater.stride)) / pushFor)
: 0
const hurry = (this.skater.speed - PACES.cruise.speed) /
(PACES.olympic.speed - PACES.cruise.speed)
const crouch = hurry > 0
? PACES.olympic.crouch * hurry
: PACES.meander.crouch * Math.min(1, -hurry * 2.3)
const fov = BASE_FOV + 12 * Math.max(-0.4, Math.min(1.1, hurry))
if (Math.abs(this.camera.fov - fov) > 0.05) {
this.camera.fov = fov
this.camera.updateProjectionMatrix()
}
const sunk = this.skater.under > 0
? Math.min(1, (3 - this.skater.under) / 0.3)
: 0
// Camera position: first person view
this.camera.position.set(
this.skater.x,
EYE - crouch + 0.03 * push - 1.3 * sunk,
this.skater.s
)
// Camera rotation
const along = Math.atan(this.skater.channel.slope(this.skater.s))
const look = this.skater.heading -
0.25 * Math.sin(this.skater.heading - along)
this.camera.rotation.set(-PITCH, -look, -0.02 * this.skater.turn * this.skater.speed)
}
gameRenderPost(): void {
// Draw HUD using LittleJS
const x = 20
let y = 40
const lineHeight = 30
drawTextScreen(
`Speed: ${Math.round(this.skater.speed * 3.6)} km/h`,
vec2(x, y),
24,
colorWhite
)
y += lineHeight
drawTextScreen(
`Pace: ${this.skater.pace}`,
vec2(x, y),
24,
colorWhite
)
y += lineHeight
drawTextScreen(
`Lake: ${this.seed}`,
vec2(x, y),
24,
colorWhite
)
y += lineHeight
drawTextScreen(
`Steer: ${this.steerValue.toFixed(2)}`,
vec2(x, y),
24,
colorWhite
)
// Instructions
drawTextScreen(
'WASD/Arrows: Steer | M: Mute | R: Random Lake | Up/Down: Change Pace',
vec2(engine.canvas.width / 2, engine.canvas.height - 30),
18,
colorWhite,
0.5
)
}
}
// Initialize POC when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
new NordicPOC()
})
} else {
new NordicPOC()
}
3. Update index.html for POC
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Nordic Skating - LittleJS POC</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
overflow: hidden;
background: #000;
font-family: sans-serif;
}
canvas {
display: block;
width: 100vw;
height: 100vh;
}
</style>
</head>
<body>
<canvas id="scene"></canvas>
<script type="module" src="/src/littlejs-poc.ts"></script>
</body>
</html>
4. Update Vite Config (if needed)
vite.config.ts:
import { defineConfig } from 'vite'
import { resolve } from 'path'
export default defineConfig({
resolve: {
alias: {
// Ensure LittleJS can be resolved
littlejsengine: resolve(__dirname, 'node_modules/littlejsengine/dist/littlejs.esm.js')
}
},
build: {
target: 'esnext'
}
})
Testing the POC
Expected Behavior
- Basic Movement: Use WASD or arrow keys to steer
- Pace Changes: Use Up/Down arrows or W/S to change pace
- Random Lake: Press R to generate a new lake
- HUD: Shows speed, pace, lake seed, and steering value
- Camera: First-person view that follows skater physics
Test Checklist
- [ ] Keyboard steering works (A/D, Arrow Left/Right)
- [ ] Mouse steering works (drag left/right)
- [ ] Gamepad steering works (if available)
- [ ] Pace changes work (Up/Down, W/S)
- [ ] Random lake generation works (R)
- [ ] HUD displays correctly
- [ ] Camera follows skater properly
- [ ] Frame rate is smooth
- [ ] No errors in console
POC Validation Criteria
The POC is successful if:
- ✅ LittleJS engine initializes without errors
- ✅ Three.js rendering works alongside LittleJS
- ✅ Input from keyboard, mouse, and gamepad is captured
- ✅ Skater physics update correctly using LittleJS delta time
- ✅ Camera positioning works as expected
- ✅ HUD rendering with LittleJS text works
- ✅ Basic lake generation is integrated
Next Steps After POC
If POC is successful:
- Full Migration: Proceed with the complete migration plan
- Incremental Approach: Migrate one system at a time
- Side-by-side Testing: Compare performance and feel with current implementation
If POC has issues:
- Debug: Identify and fix integration problems
- Adjust Plan: Modify migration approach based on findings
- Partial Integration: Use only specific LittleJS features (input, game loop) while keeping Three.js for rendering
Troubleshooting
Common Issues
1. Three.js not available in LittleJS context
- Solution: Ensure THREE is available globally or pass it explicitly
- In the POC, we set window.THREE = THREE as a fallback
2. Canvas sizing issues - Solution: Let LittleJS manage the canvas, don't manually size it - Remove any manual canvas resizing from existing code
3. Input not working - Solution: Check LittleJS input mapping - Ensure input system is initialized before use - Verify key names match LittleJS expectations
4. Audio context issues - Solution: Handle audio context creation carefully - Remember browser restrictions on autoplay - Use user interaction to unlock audio
5. Performance issues - Solution: Check for double rendering - Ensure only one render loop is running - Verify Three.js renderer is properly configured
Alternative: Minimal Integration
If the full integration proves problematic, consider a minimal integration approach:
// Only use LittleJS for input and game loop
import { engineInit, inputGetState } from 'littlejsengine'
import * as THREE from 'three'
// Keep existing Three.js setup
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(70, 1, 0.1, 1500)
// Use LittleJS just for input and timing
engineInit(
() => {}, // init
() => {
// Game update using LittleJS input
const steer = inputGetState('arrowLeft') - inputGetState('arrowRight')
// Update game state
},
() => {}, // updatePost
() => {
// Render with Three.js
renderer.render(scene, camera)
},
() => {} // renderPost
)
This approach gives you: - LittleJS input handling (better than custom implementation) - LittleJS timing (consistent delta time) - LittleJS game loop management - While keeping full control over Three.js rendering
Conclusion
The POC demonstrates that LittleJS and Three.js can coexist in the Nordic Skating project. The main benefits from even a minimal integration would be:
- Better Input Handling: LittleJS provides comprehensive input support
- Consistent Timing: Automatic delta time calculation
- Game Loop Management: No need to manually manage animation frames
- Mobile Support: Built-in touch and device orientation support
The full migration would additionally provide: 5. UI System: For dialogs and HUD 6. Audio Management: Though we may keep custom Web Audio 7. Scene Management: For different game states 8. Particle System: Could enhance visual effects
Recommendation: Start with the minimal integration (input + game loop) to validate the approach, then expand to full migration if successful.