Skip to content

Gamepad focused game program (incompatible with mouse and keyboard) created in collaboration with Claude Code.

Notifications You must be signed in to change notification settings

mmxxtdmk/silhouette_game_program

Repository files navigation

Silhouette Spin MVP Project Guide

Introduction

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.

Prerequisites

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.

Setup and Running the Project

  1. Clone or Download the Project: If this is part of a repository, clone it. Otherwise, create a new file named index.html and paste the game code into it (code not included here; refer to the original implementation).
  2. Open in Browser: Simply open the HTML file in your browser. No server is required.
  3. Connect Gamepad: Ensure your gamepad is connected via USB or Bluetooth. The game will detect it automatically.
  4. Play: Press the A button on your gamepad to start. If no gamepad is detected, the start screen will prompt you to connect one.
  5. Fullscreen Mode: The game requests fullscreen on start for an immersive experience.
  6. Debugging: Use browser developer tools (F12) to inspect the console for logs or errors.

File Structure

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.

Game Architecture

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.

1. Constants (Lines ~30-75)

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:

  1. Pulsing haptic vibration (rate varies with score)
  2. Mechanical tick sounds during rotation
  3. Success chime on level completion
  4. 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

2. Data Libraries (Lines ~76-160)

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']

3. Utility Functions (Lines ~161-185)

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.

4. Difficulty Manager (Lines ~186-210)

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

5. Input Handler (Lines ~211-280)

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):

  1. Calculate absolute value
  2. Return zero signals if below DEADZONE (0.15)
  3. Normalize to 0-1 range: (abs - DEADZONE) / (1 - DEADZONE)
  4. Restore sign
  5. Calculate discrete value (round to -10 to 10)
  6. Determine speed tier

Button Edge Detection: Compares current and previous frame to detect button press (transition from false to true).

6. Audio Manager (Lines ~281-350)

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

7. Haptic Feedback (Lines ~351-390)

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):

  1. Calculate pulse interval: 800ms - (700ms × score) = 800-100ms
  2. If enough time has elapsed, trigger pulse
  3. 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)

8. Tick Controller (Lines ~391-425)

Purpose: Converts continuous analog input into discrete rotation ticks.

State:

  • audio, haptic, difficulty: References for feedback
  • tickAccumulator: Accumulated frame time

update(speedTier, deltaTime, isFineControl):

  1. Return 0 ticks if speedTier is 'none', reset accumulator
  2. Get base ticks/second from difficulty manager
  3. Apply fine control multiplier (0.4) if right stick
  4. Add deltaTime to accumulator (normalized to 60fps)
  5. Calculate framesPerTick = 60 / ticksPerSecond
  6. Execute ticks: floor(accumulator / framesPerTick)
  7. Subtract executed ticks from accumulator
  8. Play audio and haptic feedback
  9. 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.

9. Shape 2D (Lines ~426-500)

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):

  1. Get tick counts from both controllers
  2. If leftTicks > 0:
    • Determine direction from signals.scalar.yaw (negative = CCW = +angle)
    • Apply: angle += direction × ticks × 2.5° × DEG_TO_RAD
  3. Same for rightTicks
  4. Normalize angle to 0-2π

getScore():

  1. Calculate angular difference (shortest path)
  2. Normalize: 1 - (difference / π)
  3. 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.

10. Shape 3D (Lines ~501-625)

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:

  1. Calculate sin/cos for each axis
  2. 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
  1. Multiply each vertex by matrix
  2. Store in output array with z-depth

project(scale, centerX, centerY):

  1. Project using current euler angles
  2. 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.

11. Renderer (Lines ~626-900)

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):

  1. Apply shake offset
  2. Project target vertices
  3. Draw target silhouette:
    • Green glow: lineWidth=18, alpha=score×0.3
    • Black outline: lineWidth=4
  4. Project current vertices
  5. Draw current shape:
    • Fill: HSL color shifts cyan→yellow based on score
    • Stroke: Black outline
  6. Restore transform

drawShape3D(shape, score):

  1. Apply shake offset
  2. Project vertices (offset left by 120px)
  3. Calculate face depths (average Z of 4 vertices)
  4. Sort faces back-to-front
  5. Draw faces:
    • Fill with assigned color
    • Alpha: 0.7 + (score × 0.3)
  6. Draw edges with black outlines
  7. Restore transform

draw3DTarget(shape):

  1. Calculate preview box position (right side, vertically centered)
  2. Draw green box with label "MATCH THIS"
  3. Project target vertices into box coordinates
  4. Sort faces by depth
  5. Draw faces with colors (alpha=0.9)
  6. 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

12. Game (Lines ~901-1280)

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):

  1. Calculate deltaTime (capped at 1.0 to prevent spiral of death)
  2. Update input
  3. Update state machine
  4. Render
  5. Request next frame

updateState(): State machine dispatcher. Calls appropriate handler based on current state.

startGame():

  1. Set state to 'playing'
  2. Record start time
  3. Generate first target
  4. Request fullscreen
  5. Initialize audio

updatePlaying():

  1. Check for pause (B button)
  2. Update screen shake if active
  3. If celebrating:
    • Count down celebrate frames
    • Spin shape slowly
    • Skip game logic
  4. Update shape based on mode
  5. Calculate score
  6. Update haptic feedback (pulsing vibration)
  7. Check for level completion (score ≥ threshold)
  8. 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():

  1. Calculate and add points
  2. Play success audio
  3. Increment level
  4. Update difficulty
  5. Start celebrate animation
  6. Start screen shake
  7. Reset level timer
  8. Check for 3D unlock (level 8)
  9. Check for win condition (level > 25)
  10. 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

Game Flow

This section outlines how the game progresses, useful for understanding gameplay mechanics or testing.

Start Sequence:

  1. Player connects gamepad → State: init → start
  2. Player presses A → startGame() → State: playing
  3. generateTarget() creates first challenge

Gameplay Loop (2D Mode):

  1. Player rotates shape with left stick (yaw)
  2. Optional fine tuning with right stick (roll)
  3. TickController converts input to discrete rotation steps
  4. Shape2D updates angle based on ticks
  5. Score calculated as angular alignment (0.0-1.0)
  6. Vibration pulse rate increases with score (800-100ms)
  7. When score ≥ threshold (starts at 95%, rises to 99%):
    • calculateLevelPoints() awards points
    • Screen shake animation plays
    • Shape spins in celebration
    • Next level loads

3D Mode Unlock (Level 8):

  1. Mode switches to '3d'
  2. "3D MODE UNLOCKED!" message displays
  3. Green target preview box appears on right
  4. Player now controls 3 axes (pitch/yaw/roll)
  5. Target shows desired cube orientation in preview
  6. Player matches their cube to preview orientation

Prestige System:

  1. After level 25, win screen appears
  2. If prestige < 100:
    • Option: A to prestige (gain star, restart at level 1)
    • Option: B to quit (lose all progress)
  3. If prestige = 100:
    • Option: A to play again (keep stars)
    • Option: B to quit

Quit Flow:

  1. Press B during gameplay → State: paused
  2. Press B in pause menu → State: quitconfirm
  3. Press A in quit confirm → fullReset() → State: start
  4. Press B in quit confirm → State: paused (cancel)

Key Technical Decisions

Understanding these choices helps when modifying or extending the project.

Why Tick-Based Rotation?

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.

Why Rectangular Prisms Only for 3D?

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

Why ZYX Euler Angles?

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°

Why Pulsing Vibration Instead of Continuous Audio/Vibration?

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

Reproduction and Building Steps

Follow these steps to recreate or build the project from scratch:

  1. Create a new HTML file (e.g., index.html) with a <canvas id="gameCanvas"></canvas> element.
  2. Embed basic CSS for styling (e.g., full-screen canvas, font settings).
  3. Add JavaScript: Define all constants (physics, difficulty, scoring).
  4. Create shape libraries (15 2D, 35 3D).
  5. Implement utility functions (angle math, clamping).
  6. Implement DifficultyManager (linear interpolation).
  7. Implement InputHandler (gamepad API, deadzone, normalization).
  8. Implement AudioManager (Web Audio oscillators and envelopes).
  9. Implement HapticFeedback (vibration API, pulsing).
  10. Implement TickController (frame accumulation, discrete execution).
  11. Implement Shape2D (2D rotation matrix, scoring).
  12. Implement Shape3D (ZYX Euler, 3D rotation matrix, scoring).
  13. Implement Renderer (all drawing methods, screen shake).
  14. Implement Game (state machine, game loop, level progression).
  15. Initialize and start the game loop with requestAnimationFrame.

File Size Note: The complete implementation is approximately 1,300 lines, 50KB uncompressed.

Extending the Project

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

Contributing

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.

Browser Compatibility

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

About

Gamepad focused game program (incompatible with mouse and keyboard) created in collaboration with Claude Code.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages