This document serves as a comprehensive project guide for the Silhouette Spin MVP (Minimum Viable Product). It provides an overview of the game's concept, detailed technical architecture, implementation details, and step-by-step instructions for reproduction, setup, and extension. Whether you're a developer looking to build, modify, or understand the game, this guide walks you through the structure and key decisions.
Silhouette Spin is a gamepad-controlled puzzle game where players rotate shapes to match target orientations using mechanical tick-based controls. The game progresses through 25 levels across two modes: 2D silhouette matching (levels 1-7) and 3D orientation matching (levels 8-25).
This guide is organized into sections covering the project's overview, architecture, implementation, and practical steps for getting started.
To work with this project, you'll need:
- A modern web browser (Chrome, Firefox, or Edge recommended) with support for:
- HTML5 Canvas
- Gamepad API
- Web Audio API
- Vibration API (optional for haptic feedback)
- A compatible gamepad (e.g., Xbox or PlayStation controller).
- Basic knowledge of HTML, CSS, and JavaScript (ES6+).
- No additional dependencies—everything is self-contained in a single HTML file.
- Clone or Download the Project: If this is part of a repository, clone it. Otherwise, create a new file named
index.htmland paste the game code into it (code not included here; refer to the original implementation). - Open in Browser: Simply open the HTML file in your browser. No server is required.
- Connect Gamepad: Ensure your gamepad is connected via USB or Bluetooth. The game will detect it automatically.
- Play: Press the A button on your gamepad to start. If no gamepad is detected, the start screen will prompt you to connect one.
- Fullscreen Mode: The game requests fullscreen on start for an immersive experience.
- Debugging: Use browser developer tools (F12) to inspect the console for logs or errors.
The project is contained in a single HTML file for simplicity and portability:
- HTML: Includes a Canvas element for rendering.
- CSS: Embedded styles for basic layout and HUD elements.
- JavaScript: The complete game logic (~1300 lines), structured as described in the architecture section below.
This single-file approach makes it easy to distribute and run without build tools.
Note: All the .txt files in the repository are previous versions of the game program.
The game is built modularly with classes and functions for maintainability. Below is a breakdown of the key components, including line references for navigation in the code.
Defines all game parameters for easy tuning:
Input Processing:
- DEADZONE: 0.15 (ignores stick drift below 15%)
- SPEED_SLOW_THRESHOLD: 0.33 (slow rotation zone)
- SPEED_MEDIUM_THRESHOLD: 0.66 (medium rotation zone)
- Above 0.66 = fast rotation zone
Mechanical Tick System:
- TICK_ANGLE_2D: 2.5° (rotation per tick in 2D mode)
- TICK_ANGLE_3D: 1.5° (rotation per tick in 3D mode)
- TICKS_PER_SEC_SLOW: 8 ticks/second
- TICKS_PER_SEC_MEDIUM: 16 ticks/second
- TICKS_PER_SEC_FAST: 28 ticks/second
- FINE_CONTROL_MULTIPLIER: 0.4 (right stick = 40% speed)
Feedback System:
- PULSE_MIN_INTERVAL: 100ms (fastest vibration when close)
- PULSE_MAX_INTERVAL: 800ms (slowest vibration when far)
- SHAKE_DURATION: 20 frames
- SHAKE_INTENSITY: 12 pixels
- TICK_AUDIO_DURATION: 0.03 seconds (mechanical click sound)
- TICK_HAPTIC_DURATION: 15ms (subtle vibration pulse)
Note: Continuous rising-pitch audio feedback is disabled. Feedback is provided through:
- Pulsing haptic vibration (rate varies with score)
- Mechanical tick sounds during rotation
- Success chime on level completion
- Visual score bar and color shifting
Scoring:
- BASE_POINTS: 100 per level
- SPEED_BONUS_MAX: 50 (decreases with time)
- PRECISION_BONUS: 25 (awarded at 99.5%+ match)
Difficulty Scaling:
- DIFFICULTY_MIN_SPEED: 0.7 (70% speed at level 1)
- DIFFICULTY_MAX_SPEED: 1.5 (150% speed at level 50)
- DIFFICULTY_MIN_PRECISION: 0.95 (95% match at level 1)
- DIFFICULTY_MAX_PRECISION: 0.99 (99% match at level 50)
Game Progression:
- TOTAL_LEVELS: 25
- MODE_UNLOCK_LEVEL: 8 (3D mode unlocks here)
- MAX_PRESTIGE: 100
SHAPE_LIBRARY_2D (15 shapes): Each shape contains:
- name: String (humorous name like "The Classic Octagon")
- vertices: Array of [x, y] coordinates (normalized -1 to 1)
Used for levels 1-7 (7 unique shapes, cycling through the library)
SHAPE_LIBRARY_3D (35 shapes): Each shape contains:
- name: String (humorous name)
- vertices: Array of [x, y, z] coordinates (8 vertices for rectangular prisms)
Used for levels 8-25 (18 unique shapes, cycling through the library)
All 3D shapes share:
- faces: [[0,1,2,3],[4,5,6,7],[0,1,5,4],[1,2,6,5],[2,3,7,6],[3,0,4,7]]
- edges: 12 edges connecting the 8 vertices
- faceColors: ['#e74c3c','#3498db','#2ecc71','#f39c12','#9b59b6','#1abc9c']
Utils.clamp(value, min, max): Constrains value within range.
Utils.normalizeAngle(angle): Wraps angle to 0-2π range.
Utils.angleDifference(a, b, period): Calculates shortest angular distance between two angles.
Utils.getSpeedTier(scalarInput): Maps analog input magnitude to discrete speed tier:
- |input| < 0.15 → 'none'
- |input| < 0.33 → 'slow'
- |input| < 0.66 → 'medium'
- |input| ≥ 0.66 → 'fast'
Utils.getTicksPerSecond(tier): Returns base tick rate for speed tier.
Purpose: Scales game difficulty across 50 levels.
State:
- currentLevel: 1-50
Methods:
- setLevel(level): Updates current level
- getProgress(): Returns 0.0-1.0 representing level progression
- getSpeedMultiplier(): Returns 0.7-1.5 (linear interpolation based on progress)
- getMatchThreshold(): Returns 0.95-0.99 (required match percentage)
- getTicksPerSecond(tier): Returns base tick rate × speed multiplier
Formula:
value = MIN + (MAX - MIN) * progress
where progress = (currentLevel - 1) / (TOTAL_LEVELS - 1)With 25 total levels:
- Level 1: 70% speed, 95% match required
- Level 13: 110% speed, 97% match required
- Level 25: 150% speed, 99% match required
Purpose: Processes gamepad input into normalized signals.
State:
- gamepad: Reference to connected gamepad
- connected: Boolean connection status
- signals: Object containing scalar/discrete/speedTier for yaw/pitch/roll
- buttonStates: Current and previous frame button states for edge detection
Signal Structure:
{
scalar: { yaw: -1 to 1, pitch: -1 to 1, roll: -1 to 1 },
discrete: { yaw: -10 to 10, pitch: -10 to 10, roll: -10 to 10 },
speedTier: { yaw: 'none'|'slow'|'medium'|'fast', pitch: ..., roll: ... }
}Axis Mapping:
- Left Stick X (axes[0]) → yaw (rotation around Y-axis)
- Left Stick Y (axes[1]) → pitch (rotation around X-axis, inverted)
- Right Stick X (axes[2]) → roll (fine control on yaw)
normalizeAxis(rawValue):
- Calculate absolute value
- Return zero signals if below DEADZONE (0.15)
- Normalize to 0-1 range: (abs - DEADZONE) / (1 - DEADZONE)
- Restore sign
- Calculate discrete value (round to -10 to 10)
- Determine speed tier
Button Edge Detection: Compares current and previous frame to detect button press (transition from false to true).
Purpose: Provides audio feedback using Web Audio API.
State:
- context: AudioContext
- feedbackOsc: Continuous oscillator for alignment feedback
- feedbackGain: Gain node for volume control
- initialized: Boolean flag
init(): Creates AudioContext, oscillator, and gain node. Starts oscillator with zero volume.
updateAlignmentFeedback(score): Disabled - no continuous audio feedback during gameplay.
playTick(speedTier): Creates one-shot oscillator for mechanical tick sound:
- Duration: 30ms
- Volume varies by speed: slow=0.06, medium=0.08, fast=0.1
- Frequency varies by speed: slow=250Hz, medium=350Hz, fast=450Hz
- Envelope: instant attack, exponential decay
playSuccess(): Creates ascending pitch sweep:
- Duration: 150ms
- Start: 700Hz
- End: 1120Hz (700 × 1.6)
- Volume: 0.4 with exponential decay
Purpose: Provides controller vibration feedback.
State:
- input: Reference to InputHandler
- lastPulseTime: Timestamp of last pulse (for interval tracking)
updateAlignmentFeedback(score): Implements pulsing vibration (sonar ping effect):
- Calculate pulse interval: 800ms - (700ms × score) = 800-100ms
- If enough time has elapsed, trigger pulse
- Faster pulses = closer to target
triggerPulse():
- Duration: 60ms
- Weak magnitude: 0.3 (60% of 0.5)
- Strong magnitude: 0.5
playTick():
- Duration: 15ms
- Magnitude: 0.15 (subtle)
Purpose: Converts continuous analog input into discrete rotation ticks.
State:
- audio, haptic, difficulty: References for feedback
- tickAccumulator: Accumulated frame time
update(speedTier, deltaTime, isFineControl):
- Return 0 ticks if speedTier is 'none', reset accumulator
- Get base ticks/second from difficulty manager
- Apply fine control multiplier (0.4) if right stick
- Add deltaTime to accumulator (normalized to 60fps)
- Calculate framesPerTick = 60 / ticksPerSecond
- Execute ticks: floor(accumulator / framesPerTick)
- Subtract executed ticks from accumulator
- Play audio and haptic feedback
- Return tick count
Key Insight: This creates frame-rate independent, mechanically precise rotation. Ticks only execute at exact intervals, creating distinct "steps" rather than smooth interpolation.
Purpose: Manages 2D shape state and rotation.
State:
- currentShapeIndex: Index into SHAPE_LIBRARY_2D
- vertices: Array of [x, y] coordinates
- shapeName: String
- angle: Current rotation in radians
- targetAngle: Target rotation in radians
- leftTickController, rightTickController: TickController instances
setShapeForLevel(level): Selects shape using modulo: (level - 1) % 15 This means levels 1-7 use 7 different shapes from the 15-shape library.
setRandomTarget(): Sets random target angle: 0 to 2π
update(signals, deltaTime):
- Get tick counts from both controllers
- If leftTicks > 0:
- Determine direction from signals.scalar.yaw (negative = CCW = +angle)
- Apply: angle += direction × ticks × 2.5° × DEG_TO_RAD
- Same for rightTicks
- Normalize angle to 0-2π
getScore():
- Calculate angular difference (shortest path)
- Normalize: 1 - (difference / π)
- Returns 0.0 (opposite) to 1.0 (perfect match)
project(scale, centerX, centerY): Applies 2D rotation matrix to vertices:
x' = x×cos(θ) - y×sin(θ)
y' = x×sin(θ) + y×cos(θ)
Then scales and translates to screen coordinates.
projectTarget(scale, centerX, centerY): Same as project() but uses targetAngle.
Purpose: Manages 3D shape state and rotation using Euler angles.
State:
- currentShapeIndex: Index into SHAPE_LIBRARY_3D
- shapeName: String
- vertices: Array of [x, y, z] coordinates (8 vertices)
- faces, edges, faceColors: Shared across all shapes
- euler: { x, y, z } current rotation (radians)
- targetEuler: { x, y, z } target rotation (radians)
- projected: Array of 8 projected vertices
- targetProjected: Array of 8 projected vertices
- pitchTickController, yawTickController, rollTickController: TickController instances
setShapeForLevel(level): Selects shape: (level - MODE_UNLOCK_LEVEL) % 35 This means levels 8-25 use 18 different shapes from the 35-shape library.
setRandomTarget():
- X (pitch): Random -π/2 to π/2 (clamped to prevent gimbal lock)
- Y (yaw): Random 0 to 2π
- Z (roll): Random -π/2 to π/2
- Immediately projects target for preview
update(signals, deltaTime): Updates each axis independently:
- Pitch: Up/down stick → ±X rotation, clamped to ±π/2
- Yaw: Left/right stick → ±Y rotation, wraps at 2π
- Roll: Right stick → ±Z rotation, clamped to ±π/2
getScore(): Calculates average normalized angular difference across all three axes:
dx = angleDiff(euler.x, target.x, π) / (π/2)
dy = angleDiff(euler.y, target.y, 2π) / π
dz = angleDiff(euler.z, target.z, π) / (π/2)
score = 1 - (dx + dy + dz) / 3
projectEuler(euler, output): Applies ZYX Euler rotation matrix:
- Calculate sin/cos for each axis
- Build 3×3 rotation matrix:
m00 = cy×cz, m01 = -cy×sz, m02 = sy
m10 = cx×sz + sx×sy×cz, m11 = cx×cz - sx×sy×sz, m12 = -sx×cy
m20 = sx×sz - cx×sy×cz, m21 = sx×cz + cx×sy×sz, m22 = cx×cy
- Multiply each vertex by matrix
- Store in output array with z-depth
project(scale, centerX, centerY):
- Project using current euler angles
- Scale and translate to screen coordinates (offset left by 120px to make room for target preview)
projectTarget(scale, centerX, centerY): Scales and translates pre-projected target vertices to preview box coordinates.
Purpose: Handles all drawing operations.
State:
- canvas, ctx: Canvas and 2D context
- width, height, centerX, centerY, scale: Viewport metrics
- shakeOffsetX, shakeOffsetY: Screen shake displacement
resize(): Calculates viewport and scale to fit shapes with margins.
drawShape2D(shape, score):
- Apply shake offset
- Project target vertices
- Draw target silhouette:
- Green glow: lineWidth=18, alpha=score×0.3
- Black outline: lineWidth=4
- Project current vertices
- Draw current shape:
- Fill: HSL color shifts cyan→yellow based on score
- Stroke: Black outline
- Restore transform
drawShape3D(shape, score):
- Apply shake offset
- Project vertices (offset left by 120px)
- Calculate face depths (average Z of 4 vertices)
- Sort faces back-to-front
- Draw faces:
- Fill with assigned color
- Alpha: 0.7 + (score × 0.3)
- Draw edges with black outlines
- Restore transform
draw3DTarget(shape):
- Calculate preview box position (right side, vertically centered)
- Draw green box with label "MATCH THIS"
- Project target vertices into box coordinates
- Sort faces by depth
- Draw faces with colors (alpha=0.9)
- Draw edges This is the critical fix - projectTarget() is called with correct scale/position for the preview box
drawStars(prestige): Displays gold stars (★) for prestige count:
- 1-10: Show that many stars
- 10+: Show 10 stars + "x{count}"
drawHUD(...): Displays:
- Level counter and mode
- Shape name (cyan text)
- Progress bar (green fill based on score)
- Total score (top right)
- Prestige stars (top right)
- Difficulty info (bottom left)
Screen Overlay Methods:
- drawStartScreen(): Title, controls, gamepad status
- drawUnlockScreen(): "3D MODE UNLOCKED!" message
- drawPauseScreen(): Pause menu with options
- drawQuitConfirm(): Warning about progress loss
- drawWinScreen(): Victory screen with prestige option
Purpose: Main game loop and state machine.
States:
- init: Waiting for gamepad connection
- start: Waiting for A button press
- playing: Active gameplay
- paused: Pause menu
- quitconfirm: Quit confirmation screen
- win: Victory screen after level 50
State Variables:
- mode: '2d' or '3d'
- level: 1-25
- score: 0.0-1.0 (current match percentage)
- totalScore: Accumulated points
- prestige: 0-100
- celebrateFrames: Countdown during level transition
- showUnlockFrames: Countdown for 3D unlock message
- shakeFrames: Countdown for screen shake
- deltaTime: Frame time normalized to 60fps
init(): Starts game loop, sets up event listeners.
loop(time):
- Calculate deltaTime (capped at 1.0 to prevent spiral of death)
- Update input
- Update state machine
- Render
- Request next frame
updateState(): State machine dispatcher. Calls appropriate handler based on current state.
startGame():
- Set state to 'playing'
- Record start time
- Generate first target
- Request fullscreen
- Initialize audio
updatePlaying():
- Check for pause (B button)
- Update screen shake if active
- If celebrating:
- Count down celebrate frames
- Spin shape slowly
- Skip game logic
- Update shape based on mode
- Calculate score
- Update haptic feedback (pulsing vibration)
- Check for level completion (score ≥ threshold)
- Advance level if complete
generateTarget(): Calls appropriate shape's setShapeForLevel() and setRandomTarget().
calculateLevelPoints():
base = 100
speedBonus = max(0, 50 - floor(timeElapsed / 2))
precisionBonus = score ≥ 0.995 ? 25 : 0
total = base + speedBonus + precisionBonus
advanceLevel():
- Calculate and add points
- Play success audio
- Increment level
- Update difficulty
- Start celebrate animation
- Start screen shake
- Reset level timer
- Check for 3D unlock (level 8)
- Check for win condition (level > 25)
- Generate next target
reset(): Resets game state for new playthrough, preserves prestige.
fullReset(): Resets everything including prestige (used when quitting).
render(): Dispatches rendering based on state:
- init/start: Start screen
- playing: Shape + HUD (+ 3D target if in 3D mode) (+ unlock message if showing)
- paused: Frozen game + pause overlay
- quitconfirm: Quit confirmation
- win: Victory screen
This section outlines how the game progresses, useful for understanding gameplay mechanics or testing.
- Player connects gamepad → State: init → start
- Player presses A → startGame() → State: playing
- generateTarget() creates first challenge
- Player rotates shape with left stick (yaw)
- Optional fine tuning with right stick (roll)
- TickController converts input to discrete rotation steps
- Shape2D updates angle based on ticks
- Score calculated as angular alignment (0.0-1.0)
- Vibration pulse rate increases with score (800-100ms)
- When score ≥ threshold (starts at 95%, rises to 99%):
- calculateLevelPoints() awards points
- Screen shake animation plays
- Shape spins in celebration
- Next level loads
- Mode switches to '3d'
- "3D MODE UNLOCKED!" message displays
- Green target preview box appears on right
- Player now controls 3 axes (pitch/yaw/roll)
- Target shows desired cube orientation in preview
- Player matches their cube to preview orientation
- After level 25, win screen appears
- If prestige < 100:
- Option: A to prestige (gain star, restart at level 1)
- Option: B to quit (lose all progress)
- If prestige = 100:
- Option: A to play again (keep stars)
- Option: B to quit
- Press B during gameplay → State: paused
- Press B in pause menu → State: quitconfirm
- Press A in quit confirm → fullReset() → State: start
- Press B in quit confirm → State: paused (cancel)
Understanding these choices helps when modifying or extending the project.
Traditional approach: Interpolate rotation smoothly based on input. Problem: Feels "floaty" and imprecise, continues moving after input stops.
Tick approach: Discrete rotation steps at precise intervals. Benefit: Mechanical feel, immediate stop when input released, predictable behavior.
Complex shapes (spheres, pyramids, etc.) require:
- Different vertex counts
- Different face structures
- More complex rotation math
- Larger file size
Rectangular prisms:
- Same topology (8 vertices, 6 faces, 12 edges)
- Simple variation through dimensions
- Consistent rotation behavior
- Humorous variety through naming
Alternatives: Quaternions (more complex), axis-angle (less intuitive). Euler angles:
- Direct mapping to gamepad axes
- Intuitive for players
- Sufficient for this use case
- Gimbal lock avoided by clamping pitch/roll to ±90°
Continuous feedback (rising pitch or varying intensity vibration):
- Hard to perceive small differences
- Becomes annoying during gameplay
- Distracting from visual matching task
Pulsing vibration at varying rate:
- Clear "sonar ping" effect
- Easy to interpret (fast = close)
- Less fatiguing
- Provides guidance without overwhelming the player
- Audio limited to tick sounds (mechanical feedback) and success chimes
Follow these steps to recreate or build the project from scratch:
- Create a new HTML file (e.g.,
index.html) with a<canvas id="gameCanvas"></canvas>element. - Embed basic CSS for styling (e.g., full-screen canvas, font settings).
- Add JavaScript: Define all constants (physics, difficulty, scoring).
- Create shape libraries (15 2D, 35 3D).
- Implement utility functions (angle math, clamping).
- Implement DifficultyManager (linear interpolation).
- Implement InputHandler (gamepad API, deadzone, normalization).
- Implement AudioManager (Web Audio oscillators and envelopes).
- Implement HapticFeedback (vibration API, pulsing).
- Implement TickController (frame accumulation, discrete execution).
- Implement Shape2D (2D rotation matrix, scoring).
- Implement Shape3D (ZYX Euler, 3D rotation matrix, scoring).
- Implement Renderer (all drawing methods, screen shake).
- Implement Game (state machine, game loop, level progression).
- Initialize and start the game loop with
requestAnimationFrame.
File Size Note: The complete implementation is approximately 1,300 lines, 50KB uncompressed.
- Add New Shapes: Expand SHAPE_LIBRARY_2D or _3D arrays. For 3D, maintain rectangular prism topology.
- Tune Difficulty: Adjust constants like DIFFICULTY_MIN/MAX or add non-linear scaling in DifficultyManager.
- Add Features: Implement save states (using localStorage), more modes, or mobile touch controls (as a fallback).
- Optimize: Profile with browser tools for performance on lower-end devices.
- Testing: Test on multiple browsers and gamepads. Add unit tests for utilities like angleDifference.
If this is open-source, contributions are welcome! Fork the repo, make changes, and submit a pull request. Focus on bug fixes, new shapes, or feature enhancements. Ensure code follows ES6 standards and includes comments.
Requires modern browsers with the APIs listed in Prerequisites. Tested on: Chrome, Firefox, Edge. For issues, check console logs or browser compatibility tables.
If you encounter problems or have suggestions, refer to the original technical documentation or open an issue (if in a repo).