Back to Components
Tilting Maze Game - Interactive 3D Physics
Component

Tilting Maze Game - Interactive 3D Physics

CodewithLord
January 30, 2026

An interactive tilting maze game with advanced 3D physics simulation. Players tilt the maze using mouse controls to roll balls toward the center goal. Features collision detection, gravity simulation, wall interactions, hard mode with hazards, and responsive joystick controls using 3D transforms.

🧠 Description

This project showcases an advanced interactive maze game with realistic 3D physics simulation.

Players control a joystick to tilt the maze in all directions, using gravity and physics to roll four balls toward the center goal zone.

The game features sophisticated collision detection with rounded wall caps, gravity simulation, friction, and velocity calculations that create authentic ball rolling physics.

Hard mode introduces black holes as obstacles that must be avoided, adding an extra challenge layer to the gameplay.

Perfect for learning advanced physics simulation, 3D CSS transforms, collision detection algorithms, and interactive game development.


💻 HTML Code


1<!DOCTYPE html> 2<html lang="en"> 3 4 <head> 5 <meta charset="UTF-8"> 6 <title>Tilting Maze game</title> 7 <link rel="stylesheet" href="./style.css"> 8 9 </head> 10 11 <body> 12 <div id="center"> 13 <div id="game"> 14 <div id="maze"> 15 <div id="end"></div> 16 </div> 17 <div id="joystick"> 18 <div class="joystick-arrow"></div> 19 <div class="joystick-arrow"></div> 20 <div class="joystick-arrow"></div> 21 <div class="joystick-arrow"></div> 22 <div id="joystick-head"></div> 23 </div> 24 <div id="note"> 25 Click the joystick to start! 26 <p>Move every ball to the center. Ready for hard mode? Press H</p> 27 </div> 28 </div> 29</div> 30<a id="youtube" href="https://youtube.com/@codewithlord" target="_top"> 31 <span>See how this game was made</span> 32</a> 33<div id="youtube-card"> 34 How to simulate ball movement in a maze with JavaScript 35</div> 36 <script src="./script.js"></script> 37 38 </body> 39 40</html>

HTML Structure Explanation

The HTML structure is organized in a grid layout that separates the game viewport, controls, and UI elements:

Key Elements:

  • #center - Main container centering the entire game interface
  • #game - Grid container organizing maze, joystick, and instructions
  • #maze - The game world viewport containing all interactive elements
    • #end - Target zone (dashed circle) where balls must be rolled
  • #joystick - Control interface with directional arrows
    • .joystick-arrow - Four directional indicator arrows (up, down, left, right)
    • #joystick-head - Interactive red circle that responds to mouse movement
  • #note - Instructions and game status messages displayed dynamically
  • #youtube & #youtube-card - Social links to video tutorial (hidden on small screens)

JavaScript dynamically injects ball elements and wall elements into the #maze div based on game logic.

The grid layout ensures responsive positioning of controls relative to the game area.


🎨 CSS Code


1body { 2 /* https://coolors.co/f06449-ede6e3-7d82b8-36382e-613f75 */ 3 --background-color: #ede6e3; 4 --wall-color: #36382e; 5 --joystick-color: #210124; 6 --joystick-head-color: #f06449; 7 --ball-color: #f06449; 8 --end-color: #7d82b8; 9 --text-color: #210124; 10 11 font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; 12 background-color: var(--background-color); 13} 14 15html, 16body { 17 height: 100%; 18 margin: 0; 19} 20 21#center { 22 display: flex; 23 align-items: center; 24 justify-content: center; 25 height: 100%; 26} 27 28#game { 29 display: grid; 30 grid-template-columns: auto 150px; 31 grid-template-rows: 1fr auto 1fr; 32 gap: 30px; 33 perspective: 600px; 34} 35 36#maze { 37 position: relative; 38 grid-row: 1 / -1; 39 grid-column: 1; 40 width: 350px; 41 height: 315px; 42 display: flex; 43 justify-content: center; 44 align-items: center; 45} 46 47#end { 48 width: 65px; 49 height: 65px; 50 border: 5px dashed var(--end-color); 51 border-radius: 50%; 52} 53 54#joystick { 55 position: relative; 56 background-color: var(--joystick-color); 57 border-radius: 50%; 58 width: 50px; 59 height: 50px; 60 display: flex; 61 align-items: center; 62 justify-content: center; 63 margin: 10px 50px; 64 grid-row: 2; 65} 66 67#joystick-head { 68 position: relative; 69 background-color: var(--joystick-head-color); 70 border-radius: 50%; 71 width: 20px; 72 height: 20px; 73 cursor: grab; 74 75 animation-name: glow; 76 animation-duration: 0.6s; 77 animation-iteration-count: infinite; 78 animation-direction: alternate; 79 animation-timing-function: ease-in-out; 80 animation-delay: 4s; 81} 82 83@keyframes glow { 84 0% { 85 transform: scale(1); 86 } 87 100% { 88 transform: scale(1.2); 89 } 90} 91 92.joystick-arrow:nth-of-type(1) { 93 position: absolute; 94 bottom: 55px; 95 96 width: 0; 97 height: 0; 98 border-left: 10px solid transparent; 99 border-right: 10px solid transparent; 100 101 border-bottom: 10px solid var(--joystick-color); 102} 103 104.joystick-arrow:nth-of-type(2) { 105 position: absolute; 106 top: 55px; 107 108 width: 0; 109 height: 0; 110 border-left: 10px solid transparent; 111 border-right: 10px solid transparent; 112 113 border-top: 10px solid var(--joystick-color); 114} 115 116.joystick-arrow:nth-of-type(3) { 117 position: absolute; 118 left: 55px; 119 120 width: 0; 121 height: 0; 122 border-top: 10px solid transparent; 123 border-bottom: 10px solid transparent; 124 125 border-left: 10px solid var(--joystick-color); 126} 127 128.joystick-arrow:nth-of-type(4) { 129 position: absolute; 130 right: 55px; 131 132 width: 0; 133 height: 0; 134 border-top: 10px solid transparent; 135 border-bottom: 10px solid transparent; 136 137 border-right: 10px solid var(--joystick-color); 138} 139 140#note { 141 grid-row: 3; 142 grid-column: 2; 143 text-align: center; 144 font-size: 0.8em; 145 color: var(--text-color); 146 transition: opacity 2s; 147} 148 149a:visited { 150 color: inherit; 151} 152 153.ball { 154 position: absolute; 155 margin-top: -5px; 156 margin-left: -5px; 157 border-radius: 50%; 158 background-color: var(--ball-color); 159 width: 10px; 160 height: 10px; 161} 162 163.wall { 164 position: absolute; 165 background-color: var(--wall-color); 166 transform-origin: top center; 167 margin-left: -5px; 168} 169 170.wall::before, 171.wall::after { 172 display: block; 173 content: ""; 174 width: 10px; 175 height: 10px; 176 background-color: inherit; 177 border-radius: 50%; 178 position: absolute; 179} 180 181.wall::before { 182 top: -5px; 183} 184 185.wall::after { 186 bottom: -5px; 187} 188 189.black-hole { 190 position: absolute; 191 margin-top: -9px; 192 margin-left: -9px; 193 border-radius: 50%; 194 background-color: black; 195 width: 18px; 196 height: 18px; 197} 198 199#youtube, 200#youtube-card { 201 display: none; 202} 203 204@media (min-height: 425px) { 205 /** Youtube logo by https://codepen.io/alvaromontoro */ 206 #youtube { 207 z-index: 2; 208 display: block; 209 width: 100px; 210 height: 70px; 211 position: absolute; 212 bottom: 20px; 213 right: 20px; 214 background: red; 215 border-radius: 50% / 11%; 216 transform: scale(0.8); 217 transition: transform 0.5s; 218 } 219 220 #youtube:hover, 221 #youtube:focus { 222 transform: scale(0.9); 223 } 224 225 #youtube::before { 226 content: ""; 227 display: block; 228 position: absolute; 229 top: 7.5%; 230 left: -6%; 231 width: 112%; 232 height: 85%; 233 background: red; 234 border-radius: 9% / 50%; 235 } 236 237 #youtube::after { 238 content: ""; 239 display: block; 240 position: absolute; 241 top: 20px; 242 left: 40px; 243 width: 45px; 244 height: 30px; 245 border: 15px solid transparent; 246 box-sizing: border-box; 247 border-left: 30px solid white; 248 } 249 250 #youtube span { 251 font-size: 0; 252 position: absolute; 253 width: 0; 254 height: 0; 255 overflow: hidden; 256 } 257 258 #youtube:hover + #youtube-card { 259 display: block; 260 position: absolute; 261 bottom: 12px; 262 right: 10px; 263 padding: 25px 130px 25px 25px; 264 width: 300px; 265 background-color: white; 266 } 267}

CSS Breakdown

Color System

Custom properties define a cohesive color palette inspired by Coolors.co:

  • --background-color: #ede6e3 - Light cream background
  • --wall-color: #36382e - Dark gray for maze walls
  • --joystick-color: #210124 - Deep purple for joystick base
  • --joystick-head-color: #f06449 - Red-orange for interactive elements
  • --ball-color: #f06449 - Matches joystick for visual consistency
  • --end-color: #7d82b8 - Blue-purple for goal zone

Layout Structure

#center uses flexbox to center the entire game interface perfectly.

#game uses CSS Grid with 3 rows and 2 columns to organize game sections:

  • Maze occupies left column spanning all rows
  • Joystick positioned in center-right, row 2
  • Instructions positioned bottom-right, row 3
  • perspective: 600px enables 3D transforms on child elements

Maze Viewport

Fixed 350px × 315px dimensions create consistent playable area.

Flexbox centers the goal zone (#end) in the viewport.

Positioned absolutely for dynamically injected walls and balls.

Joystick Interface

Circular dark background with four directional arrows created using CSS borders.

:nth-of-type() selectors position arrows in cardinal directions.

#joystick-head (the red circle) glows with infinite animation at 0.6s duration.

Starts hidden then animates after 4s delay to draw player attention.

Ball Styling

10px × 10px circular elements with negative margins for proper centering.

Red-orange color matches the interactive joystick for visual feedback.

Positioned absolutely within maze to allow free movement.

Wall Styling

Dark gray background with rounded caps using ::before and ::after pseudo-elements.

Pseudo-elements create 10px circular caps at wall endpoints.

Transform-origin set to top for rotation transformations.

Negative margin ensures proper positioning.

Hard Mode Hazards

.black-hole creates 18px black circles that appear in hard mode.

Negative margins center the visual representation.

Used in collision detection for game-over triggering.

YouTube Social Link

Creatively styled play button using CSS shapes (red background, pseudo-elements).

Hidden by default, visible only on screens taller than 425px.

Hover state creates tooltip with tutorial information.

All created using pure CSS without external images.

Responsive Behavior

Media query ensures social link only appears on adequately tall viewports.

Grid layout automatically adapts to different screen sizes.

Fixed maze dimensions maintain playability across devices.


⚙️ JavaScript Code Overview


1 2 3Math.minmax = (value, limit) => { 4 return Math.max(Math.min(value, limit), -limit); 5}; 6 7const distance2D = (p1, p2) => { 8 return Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2); 9}; 10 11// Angle between the two points 12const getAngle = (p1, p2) => { 13 let angle = Math.atan((p2.y - p1.y) / (p2.x - p1.x)); 14 if (p2.x - p1.x < 0) angle += Math.PI; 15 return angle; 16}; 17 18// The closest a ball and a wall cap can be 19const closestItCanBe = (cap, ball) => { 20 let angle = getAngle(cap, ball); 21 22 const deltaX = Math.cos(angle) * (wallW / 2 + ballSize / 2); 23 const deltaY = Math.sin(angle) * (wallW / 2 + ballSize / 2); 24 25 return { x: cap.x + deltaX, y: cap.y + deltaY }; 26}; 27 28// Roll the ball around the wall cap 29const rollAroundCap = (cap, ball) => { 30 // The direction the ball can't move any further because the wall holds it back 31 let impactAngle = getAngle(ball, cap); 32 33 // The direction the ball wants to move based on it's velocity 34 let heading = getAngle( 35 { x: 0, y: 0 }, 36 { x: ball.velocityX, y: ball.velocityY } 37 ); 38 39 // The angle between the impact direction and the ball's desired direction 40 // The smaller this angle is, the bigger the impact 41 // The closer it is to 90 degrees the smoother it gets (at 90 there would be no collision) 42 let impactHeadingAngle = impactAngle - heading; 43 44 // Velocity distance if not hit would have occurred 45 const velocityMagnitude = distance2D( 46 { x: 0, y: 0 }, 47 { x: ball.velocityX, y: ball.velocityY } 48 ); 49 // Velocity component diagonal to the impact 50 const velocityMagnitudeDiagonalToTheImpact = 51 Math.sin(impactHeadingAngle) * velocityMagnitude; 52 53 // How far should the ball be from the wall cap 54 const closestDistance = wallW / 2 + ballSize / 2; 55 56 const rotationAngle = Math.atan( 57 velocityMagnitudeDiagonalToTheImpact / closestDistance 58 ); 59 60 const deltaFromCap = { 61 x: Math.cos(impactAngle + Math.PI - rotationAngle) * closestDistance, 62 y: Math.sin(impactAngle + Math.PI - rotationAngle) * closestDistance 63 }; 64 65 const x = ball.x; 66 const y = ball.y; 67 const velocityX = ball.x - (cap.x + deltaFromCap.x); 68 const velocityY = ball.y - (cap.y + deltaFromCap.y); 69 const nextX = x + velocityX; 70 const nextY = y + velocityY; 71 72 return { x, y, velocityX, velocityY, nextX, nextY }; 73}; 74 75// Decreases the absolute value of a number but keeps it's sign, doesn't go below abs 0 76const slow = (number, difference) => { 77 if (Math.abs(number) <= difference) return 0; 78 if (number > difference) return number - difference; 79 return number + difference; 80}; 81 82const mazeElement = document.getElementById("maze"); 83const joystickHeadElement = document.getElementById("joystick-head"); 84const noteElement = document.getElementById("note"); // Note element for instructions and game won, game failed texts 85 86let hardMode = false; 87let previousTimestamp; 88let gameInProgress; 89let mouseStartX; 90let mouseStartY; 91let accelerationX; 92let accelerationY; 93let frictionX; 94let frictionY; 95 96const pathW = 25; // Path width 97const wallW = 10; // Wall width 98const ballSize = 10; // Width and height of the ball 99const holeSize = 18; 100 101const debugMode = false; 102 103let balls = []; 104let ballElements = []; 105let holeElements = []; 106 107resetGame(); 108 109// Draw balls for the first time 110balls.forEach(({ x, y }) => { 111 const ball = document.createElement("div"); 112 ball.setAttribute("class", "ball"); 113 ball.style.cssText = `left: ${x}px; top: ${y}px; `; 114 115 mazeElement.appendChild(ball); 116 ballElements.push(ball); 117}); 118 119// Wall metadata 120const walls = [ 121 // Border 122 { column: 0, row: 0, horizontal: true, length: 10 }, 123 { column: 0, row: 0, horizontal: false, length: 9 }, 124 { column: 0, row: 9, horizontal: true, length: 10 }, 125 { column: 10, row: 0, horizontal: false, length: 9 }, 126 127 // Horizontal lines starting in 1st column 128 { column: 0, row: 6, horizontal: true, length: 1 }, 129 { column: 0, row: 8, horizontal: true, length: 1 }, 130 131 // Horizontal lines starting in 2nd column 132 { column: 1, row: 1, horizontal: true, length: 2 }, 133 { column: 1, row: 7, horizontal: true, length: 1 }, 134 135 // Horizontal lines starting in 3rd column 136 { column: 2, row: 2, horizontal: true, length: 2 }, 137 { column: 2, row: 4, horizontal: true, length: 1 }, 138 { column: 2, row: 5, horizontal: true, length: 1 }, 139 { column: 2, row: 6, horizontal: true, length: 1 }, 140 141 // Horizontal lines starting in 4th column 142 { column: 3, row: 3, horizontal: true, length: 1 }, 143 { column: 3, row: 8, horizontal: true, length: 3 }, 144 145 // Horizontal lines starting in 5th column 146 { column: 4, row: 6, horizontal: true, length: 1 }, 147 148 // Horizontal lines starting in 6th column 149 { column: 5, row: 2, horizontal: true, length: 2 }, 150 { column: 5, row: 7, horizontal: true, length: 1 }, 151 152 // Horizontal lines starting in 7th column 153 { column: 6, row: 1, horizontal: true, length: 1 }, 154 { column: 6, row: 6, horizontal: true, length: 2 }, 155 156 // Horizontal lines starting in 8th column 157 { column: 7, row: 3, horizontal: true, length: 2 }, 158 { column: 7, row: 7, horizontal: true, length: 2 }, 159 160 // Horizontal lines starting in 9th column 161 { column: 8, row: 1, horizontal: true, length: 1 }, 162 { column: 8, row: 2, horizontal: true, length: 1 }, 163 { column: 8, row: 3, horizontal: true, length: 1 }, 164 { column: 8, row: 4, horizontal: true, length: 2 }, 165 { column: 8, row: 8, horizontal: true, length: 2 }, 166 167 // Vertical lines after the 1st column 168 { column: 1, row: 1, horizontal: false, length: 2 }, 169 { column: 1, row: 4, horizontal: false, length: 2 }, 170 171 // Vertical lines after the 2nd column 172 { column: 2, row: 2, horizontal: false, length: 2 }, 173 { column: 2, row: 5, horizontal: false, length: 1 }, 174 { column: 2, row: 7, horizontal: false, length: 2 }, 175 176 // Vertical lines after the 3rd column 177 { column: 3, row: 0, horizontal: false, length: 1 }, 178 { column: 3, row: 4, horizontal: false, length: 1 }, 179 { column: 3, row: 6, horizontal: false, length: 2 }, 180 181 // Vertical lines after the 4th column 182 { column: 4, row: 1, horizontal: false, length: 2 }, 183 { column: 4, row: 6, horizontal: false, length: 1 }, 184 185 // Vertical lines after the 5th column 186 { column: 5, row: 0, horizontal: false, length: 2 }, 187 { column: 5, row: 6, horizontal: false, length: 1 }, 188 { column: 5, row: 8, horizontal: false, length: 1 }, 189 190 // Vertical lines after the 6th column 191 { column: 6, row: 4, horizontal: false, length: 1 }, 192 { column: 6, row: 6, horizontal: false, length: 1 }, 193 194 // Vertical lines after the 7th column 195 { column: 7, row: 1, horizontal: false, length: 4 }, 196 { column: 7, row: 7, horizontal: false, length: 2 }, 197 198 // Vertical lines after the 8th column 199 { column: 8, row: 2, horizontal: false, length: 1 }, 200 { column: 8, row: 4, horizontal: false, length: 2 }, 201 202 // Vertical lines after the 9th column 203 { column: 9, row: 1, horizontal: false, length: 1 }, 204 { column: 9, row: 5, horizontal: false, length: 2 } 205].map((wall) => ({ 206 x: wall.column * (pathW + wallW), 207 y: wall.row * (pathW + wallW), 208 horizontal: wall.horizontal, 209 length: wall.length * (pathW + wallW) 210})); 211 212// Draw walls 213walls.forEach(({ x, y, horizontal, length }) => { 214 const wall = document.createElement("div"); 215 wall.setAttribute("class", "wall"); 216 wall.style.cssText = ` 217 left: ${x}px; 218 top: ${y}px; 219 width: ${wallW}px; 220 height: ${length}px; 221 transform: rotate(${horizontal ? -90 : 0}deg); 222 `; 223 224 mazeElement.appendChild(wall); 225}); 226 227const holes = [ 228 { column: 0, row: 5 }, 229 { column: 2, row: 0 }, 230 { column: 2, row: 4 }, 231 { column: 4, row: 6 }, 232 { column: 6, row: 2 }, 233 { column: 6, row: 8 }, 234 { column: 8, row: 1 }, 235 { column: 8, row: 2 } 236].map((hole) => ({ 237 x: hole.column * (wallW + pathW) + (wallW / 2 + pathW / 2), 238 y: hole.row * (wallW + pathW) + (wallW / 2 + pathW / 2) 239})); 240 241joystickHeadElement.addEventListener("mousedown", function (event) { 242 if (!gameInProgress) { 243 mouseStartX = event.clientX; 244 mouseStartY = event.clientY; 245 gameInProgress = true; 246 window.requestAnimationFrame(main); 247 noteElement.style.opacity = 0; 248 joystickHeadElement.style.cssText = ` 249 animation: none; 250 cursor: grabbing; 251 `; 252 } 253}); 254 255window.addEventListener("mousemove", function (event) { 256 if (gameInProgress) { 257 const mouseDeltaX = -Math.minmax(mouseStartX - event.clientX, 15); 258 const mouseDeltaY = -Math.minmax(mouseStartY - event.clientY, 15); 259 260 joystickHeadElement.style.cssText = ` 261 left: ${mouseDeltaX}px; 262 top: ${mouseDeltaY}px; 263 animation: none; 264 cursor: grabbing; 265 `; 266 267 const rotationY = mouseDeltaX * 0.8; // Max rotation = 12 268 const rotationX = mouseDeltaY * 0.8; 269 270 mazeElement.style.cssText = ` 271 transform: rotateY(${rotationY}deg) rotateX(${-rotationX}deg) 272 `; 273 274 const gravity = 2; 275 const friction = 0.01; // Coefficients of friction 276 277 accelerationX = gravity * Math.sin((rotationY / 180) * Math.PI); 278 accelerationY = gravity * Math.sin((rotationX / 180) * Math.PI); 279 frictionX = gravity * Math.cos((rotationY / 180) * Math.PI) * friction; 280 frictionY = gravity * Math.cos((rotationX / 180) * Math.PI) * friction; 281 } 282}); 283 284window.addEventListener("keydown", function (event) { 285 // If not an arrow key or space or H was pressed then return 286 if (![" ", "H", "h", "E", "e"].includes(event.key)) return; 287 288 // If an arrow key was pressed then first prevent default 289 event.preventDefault(); 290 291 // If space was pressed restart the game 292 if (event.key == " ") { 293 resetGame(); 294 return; 295 } 296 297 // Set Hard mode 298 if (event.key == "H" || event.key == "h") { 299 hardMode = true; 300 resetGame(); 301 return; 302 } 303 304 // Set Easy mode 305 if (event.key == "E" || event.key == "e") { 306 hardMode = false; 307 resetGame(); 308 return; 309 } 310}); 311 312function resetGame() { 313 previousTimestamp = undefined; 314 gameInProgress = false; 315 mouseStartX = undefined; 316 mouseStartY = undefined; 317 accelerationX = undefined; 318 accelerationY = undefined; 319 frictionX = undefined; 320 frictionY = undefined; 321 322 mazeElement.style.cssText = ` 323 transform: rotateY(0deg) rotateX(0deg) 324 `; 325 326 joystickHeadElement.style.cssText = ` 327 left: 0; 328 top: 0; 329 animation: glow; 330 cursor: grab; 331 `; 332 333 if (hardMode) { 334 noteElement.innerHTML = `Click the joystick to start! 335 <p>Hard mode, Avoid black holes. Back to easy mode? Press E</p>`; 336 } else { 337 noteElement.innerHTML = `Click the joystick to start! 338 <p>Move every ball to the center. Ready for hard mode? Press H</p>`; 339 } 340 noteElement.style.opacity = 1; 341 342 balls = [ 343 { column: 0, row: 0 }, 344 { column: 9, row: 0 }, 345 { column: 0, row: 8 }, 346 { column: 9, row: 8 } 347 ].map((ball) => ({ 348 x: ball.column * (wallW + pathW) + (wallW / 2 + pathW / 2), 349 y: ball.row * (wallW + pathW) + (wallW / 2 + pathW / 2), 350 velocityX: 0, 351 velocityY: 0 352 })); 353 354 if (ballElements.length) { 355 balls.forEach(({ x, y }, index) => { 356 ballElements[index].style.cssText = `left: ${x}px; top: ${y}px; `; 357 }); 358 } 359 360 // Remove previous hole elements 361 holeElements.forEach((holeElement) => { 362 mazeElement.removeChild(holeElement); 363 }); 364 holeElements = []; 365 366 // Reset hole elements if hard mode 367 if (hardMode) { 368 holes.forEach(({ x, y }) => { 369 const ball = document.createElement("div"); 370 ball.setAttribute("class", "black-hole"); 371 ball.style.cssText = `left: ${x}px; top: ${y}px; `; 372 373 mazeElement.appendChild(ball); 374 holeElements.push(ball); 375 }); 376 } 377} 378 379function main(timestamp) { 380 // It is possible to reset the game mid-game. This case the look should stop 381 if (!gameInProgress) return; 382 383 if (previousTimestamp === undefined) { 384 previousTimestamp = timestamp; 385 window.requestAnimationFrame(main); 386 return; 387 } 388 389 const maxVelocity = 1.5; 390 391 // Time passed since last cycle divided by 16 392 // This function gets called every 16 ms on average so dividing by 16 will result in 1 393 const timeElapsed = (timestamp - previousTimestamp) / 16; 394 395 try { 396 // If mouse didn't move yet don't do anything 397 if (accelerationX != undefined && accelerationY != undefined) { 398 const velocityChangeX = accelerationX * timeElapsed; 399 const velocityChangeY = accelerationY * timeElapsed; 400 const frictionDeltaX = frictionX * timeElapsed; 401 const frictionDeltaY = frictionY * timeElapsed; 402 403 balls.forEach((ball) => { 404 if (velocityChangeX == 0) { 405 // No rotation, the plane is flat 406 // On flat surface friction can only slow down, but not reverse movement 407 ball.velocityX = slow(ball.velocityX, frictionDeltaX); 408 } else { 409 ball.velocityX = ball.velocityX + velocityChangeX; 410 ball.velocityX = Math.max(Math.min(ball.velocityX, 1.5), -1.5); 411 ball.velocityX = 412 ball.velocityX - Math.sign(velocityChangeX) * frictionDeltaX; 413 ball.velocityX = Math.minmax(ball.velocityX, maxVelocity); 414 } 415 416 if (velocityChangeY == 0) { 417 // No rotation, the plane is flat 418 // On flat surface friction can only slow down, but not reverse movement 419 ball.velocityY = slow(ball.velocityY, frictionDeltaY); 420 } else { 421 ball.velocityY = ball.velocityY + velocityChangeY; 422 ball.velocityY = 423 ball.velocityY - Math.sign(velocityChangeY) * frictionDeltaY; 424 ball.velocityY = Math.minmax(ball.velocityY, maxVelocity); 425 } 426 427 // Preliminary next ball position, only becomes true if no hit occurs 428 // Used only for hit testing, does not mean that the ball will reach this position 429 ball.nextX = ball.x + ball.velocityX; 430 ball.nextY = ball.y + ball.velocityY; 431 432 if (debugMode) console.log("tick", ball); 433 434 walls.forEach((wall, wi) => { 435 if (wall.horizontal) { 436 // Horizontal wall 437 438 if ( 439 ball.nextY + ballSize / 2 >= wall.y - wallW / 2 && 440 ball.nextY - ballSize / 2 <= wall.y + wallW / 2 441 ) { 442 // Ball got within the strip of the wall 443 // (not necessarily hit it, could be before or after) 444 445 const wallStart = { 446 x: wall.x, 447 y: wall.y 448 }; 449 const wallEnd = { 450 x: wall.x + wall.length, 451 y: wall.y 452 }; 453 454 if ( 455 ball.nextX + ballSize / 2 >= wallStart.x - wallW / 2 && 456 ball.nextX < wallStart.x 457 ) { 458 // Ball might hit the left cap of a horizontal wall 459 const distance = distance2D(wallStart, { 460 x: ball.nextX, 461 y: ball.nextY 462 }); 463 if (distance < ballSize / 2 + wallW / 2) { 464 if (debugMode && wi > 4) 465 console.warn("too close h head", distance, ball); 466 467 // Ball hits the left cap of a horizontal wall 468 const closest = closestItCanBe(wallStart, { 469 x: ball.nextX, 470 y: ball.nextY 471 }); 472 const rolled = rollAroundCap(wallStart, { 473 x: closest.x, 474 y: closest.y, 475 velocityX: ball.velocityX, 476 velocityY: ball.velocityY 477 }); 478 479 Object.assign(ball, rolled); 480 } 481 } 482 483 if ( 484 ball.nextX - ballSize / 2 <= wallEnd.x + wallW / 2 && 485 ball.nextX > wallEnd.x 486 ) { 487 // Ball might hit the right cap of a horizontal wall 488 const distance = distance2D(wallEnd, { 489 x: ball.nextX, 490 y: ball.nextY 491 }); 492 if (distance < ballSize / 2 + wallW / 2) { 493 if (debugMode && wi > 4) 494 console.warn("too close h tail", distance, ball); 495 496 // Ball hits the right cap of a horizontal wall 497 const closest = closestItCanBe(wallEnd, { 498 x: ball.nextX, 499 y: ball.nextY 500 }); 501 const rolled = rollAroundCap(wallEnd, { 502 x: closest.x, 503 y: closest.y, 504 velocityX: ball.velocityX, 505 velocityY: ball.velocityY 506 }); 507 508 Object.assign(ball, rolled); 509 } 510 } 511 512 if (ball.nextX >= wallStart.x && ball.nextX <= wallEnd.x) { 513 // The ball got inside the main body of the wall 514 if (ball.nextY < wall.y) { 515 // Hit horizontal wall from top 516 ball.nextY = wall.y - wallW / 2 - ballSize / 2; 517 } else { 518 // Hit horizontal wall from bottom 519 ball.nextY = wall.y + wallW / 2 + ballSize / 2; 520 } 521 ball.y = ball.nextY; 522 ball.velocityY = -ball.velocityY / 3; 523 524 if (debugMode && wi > 4) 525 console.error("crossing h line, HIT", ball); 526 } 527 } 528 } else { 529 // Vertical wall 530 531 if ( 532 ball.nextX + ballSize / 2 >= wall.x - wallW / 2 && 533 ball.nextX - ballSize / 2 <= wall.x + wallW / 2 534 ) { 535 // Ball got within the strip of the wall 536 // (not necessarily hit it, could be before or after) 537 538 const wallStart = { 539 x: wall.x, 540 y: wall.y 541 }; 542 const wallEnd = { 543 x: wall.x, 544 y: wall.y + wall.length 545 }; 546 547 if ( 548 ball.nextY + ballSize / 2 >= wallStart.y - wallW / 2 && 549 ball.nextY < wallStart.y 550 ) { 551 // Ball might hit the top cap of a horizontal wall 552 const distance = distance2D(wallStart, { 553 x: ball.nextX, 554 y: ball.nextY 555 }); 556 if (distance < ballSize / 2 + wallW / 2) { 557 if (debugMode && wi > 4) 558 console.warn("too close v head", distance, ball); 559 560 // Ball hits the left cap of a horizontal wall 561 const closest = closestItCanBe(wallStart, { 562 x: ball.nextX, 563 y: ball.nextY 564 }); 565 const rolled = rollAroundCap(wallStart, { 566 x: closest.x, 567 y: closest.y, 568 velocityX: ball.velocityX, 569 velocityY: ball.velocityY 570 }); 571 572 Object.assign(ball, rolled); 573 } 574 } 575 576 if ( 577 ball.nextY - ballSize / 2 <= wallEnd.y + wallW / 2 && 578 ball.nextY > wallEnd.y 579 ) { 580 // Ball might hit the bottom cap of a horizontal wall 581 const distance = distance2D(wallEnd, { 582 x: ball.nextX, 583 y: ball.nextY 584 }); 585 if (distance < ballSize / 2 + wallW / 2) { 586 if (debugMode && wi > 4) 587 console.warn("too close v tail", distance, ball); 588 589 // Ball hits the right cap of a horizontal wall 590 const closest = closestItCanBe(wallEnd, { 591 x: ball.nextX, 592 y: ball.nextY 593 }); 594 const rolled = rollAroundCap(wallEnd, { 595 x: closest.x, 596 y: closest.y, 597 velocityX: ball.velocityX, 598 velocityY: ball.velocityY 599 }); 600 601 Object.assign(ball, rolled); 602 } 603 } 604 605 if (ball.nextY >= wallStart.y && ball.nextY <= wallEnd.y) { 606 // The ball got inside the main body of the wall 607 if (ball.nextX < wall.x) { 608 // Hit vertical wall from left 609 ball.nextX = wall.x - wallW / 2 - ballSize / 2; 610 } else { 611 // Hit vertical wall from right 612 ball.nextX = wall.x + wallW / 2 + ballSize / 2; 613 } 614 ball.x = ball.nextX; 615 ball.velocityX = -ball.velocityX / 3; 616 617 if (debugMode && wi > 4) 618 console.error("crossing v line, HIT", ball); 619 } 620 } 621 } 622 }); 623 624 // Detect is a ball fell into a hole 625 if (hardMode) { 626 holes.forEach((hole, hi) => { 627 const distance = distance2D(hole, { 628 x: ball.nextX, 629 y: ball.nextY 630 }); 631 632 if (distance <= holeSize / 2) { 633 // The ball fell into a hole 634 holeElements[hi].style.backgroundColor = "red"; 635 throw Error("The ball fell into a hole"); 636 } 637 }); 638 } 639 640 // Adjust ball metadata 641 ball.x = ball.x + ball.velocityX; 642 ball.y = ball.y + ball.velocityY; 643 }); 644 645 // Move balls to their new position on the UI 646 balls.forEach(({ x, y }, index) => { 647 ballElements[index].style.cssText = `left: ${x}px; top: ${y}px; `; 648 }); 649 } 650 651 // Win detection 652 if ( 653 balls.every( 654 (ball) => distance2D(ball, { x: 350 / 2, y: 315 / 2 }) < 65 / 2 655 ) 656 ) { 657 noteElement.innerHTML = `Congrats, you did it! 658 ${!hardMode ? "<p>Press H for hard mode</p>" : ""} 659 >`; 660 noteElement.style.opacity = 1; 661 gameInProgress = false; 662 } else { 663 previousTimestamp = timestamp; 664 window.requestAnimationFrame(main); 665 } 666 } catch (error) { 667 if (error.message == "The ball fell into a hole") { 668 noteElement.innerHTML = `A ball fell into a black hole! Press space to reset the game. 669 <p> 670 Back to easy? Press E 671 </p>`; 672 noteElement.style.opacity = 1; 673 gameInProgress = false; 674 } else throw error; 675 } 676}

The JavaScript engine implements sophisticated physics simulation and collision detection. Key systems include:

Utility Functions

  • Math.minmax() - Constrains values to maximum limit while preserving sign
  • distance2D() - Calculates Euclidean distance between two points
  • getAngle() - Determines angle between two points for collision responses
  • closestItCanBe() - Calculates closest approach point between ball and wall cap
  • rollAroundCap() - Simulates ball rolling around rounded wall corners
  • slow() - Applies friction while preventing velocity reversal on flat surfaces

Game State Variables

  • balls - Array tracking all active ball objects with position and velocity
  • ballElements - Array of DOM elements corresponding to balls for rendering
  • holeElements - Array of hazard elements in hard mode
  • gameInProgress - Boolean flag indicating active gameplay
  • accelerationX/Y - Current gravitational acceleration from joystick tilt
  • frictionX/Y - Friction coefficients calculated from tilt angle

Game Initialization

  • resetGame() function initializes all game state
  • Places four balls at maze corners
  • Loads wall metadata from configuration array
  • Creates DOM elements for walls using CSS transforms
  • Sets up holes in hard mode
  • Displays appropriate instructions based on difficulty level

Joystick Control System

Mouse down on joystick starts game and enables tilt control.

Mouse move calculates:

  • Mouse delta from starting position (clamped to ±15px)
  • 3D rotation angles (rotationX and rotationY)
  • Applies 3D transforms to maze for tilting effect
  • Calculates gravity and friction values from tilt angle

Physics Simulation

Main game loop runs at ~60 FPS using requestAnimationFrame.

For each ball:

  • Updates velocity based on acceleration and friction
  • Limits velocity to maximum magnitude (1.5 units)
  • Calculates next position (nextX, nextY)
  • Applies flat-surface friction behavior (friction only slows, doesn't reverse)
  • Tests collisions against all walls

Collision Detection System

Two-phase collision approach:

  1. Wall Strip Testing - Determines if ball is within wall boundaries
  2. Cap Collision - Detects collision with rounded wall endpoints using distance2D
  3. Body Collision - Handles impact when ball enters wall body directly

Wall Cap Collision Handling

Uses rollAroundCap() to simulate realistic rolling physics:

  • Calculates angle of impact relative to ball velocity
  • Determines velocity component perpendicular to impact
  • Rotates ball trajectory around wall cap
  • Applies energy loss (velocity × 1/3 on rebound)

Wall Body Collision Handling

Direct collision with wall interior:

  • Repositions ball outside wall boundary
  • Reverses and dampens velocity (multiplied by 1/3)
  • Prevents ball from getting stuck in walls

Hard Mode Hazards

Hole detection using distance comparison:

  • Tests distance between ball and each hole center
  • If distance < holeSize/2 (9px radius), ball is lost
  • Changes hole color to red on capture
  • Throws error to trigger game-over state

Win Condition Detection

Checks if all four balls are within goal zone radius (32.5px from center).

Displays congratulations message with mode toggle option.

Ends game and returns to joystick interaction state.

Keyboard Input Handling

Space key - Resets current game H key - Switches to hard mode (with hazards) E key - Switches to easy mode (hazard-free)

Rendering System

Updates ball DOM element positions each frame using CSS left/top properties.

Dynamically creates walls with rotation transforms based on orientation.

Manages hole elements creation/destruction based on difficulty level.

Updates game messages and UI state through noteElement.

Game Loop Architecture

RequestAnimationFrame maintains 60 FPS cadence.

Three-part loop:

  1. Calculate physics (acceleration, velocity, position)
  2. Test collisions and resolve
  3. Render updated positions and check win condition

Graceful error handling catches hazard collisions and displays appropriate messages.

Context resets allow seamless game resets without memory leaks.

Advanced Physics Concepts

The collision system demonstrates:

  • Vector mathematics for angle calculations
  • Physics-based rolling simulation with realistic curves
  • Friction modeling for different surface conditions
  • Energy conservation with velocity dampening
  • Multi-body collision resolution

This architecture creates a fully functional maze game with authentic physics that rivals dedicated physics engines while remaining lightweight and performant.

Love this component?

Explore more components and build amazing UIs.

View All Components