Netbyzz

#A057
Cubes Smashed Game

HTML :

<!-- CODE = #A057 -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>NETBYZZ A057</title>
    <link rel="stylesheet" href="style.css">
  </head>
  <body>
    <!-- Game canvas -->
    <canvas id="c"></canvas>

    <!-- Gameplay HUD -->
    <div class="hud">
      <div class="hud__score">
        <div class="score-lbl"></div>
        <div class="cube-count-lbl"></div>
      </div>
      <div class="pause-btn"><div></div></div>
      <div class="slowmo">
        <div class="slowmo__bar"></div>
      </div>
    </div>

    <!-- Menu System -->
    <div class="menus">
      <div class="menu menu--main">
        <h1>MENJA</h1>
        <button type="button" class="play-normal-btn">PLAY GAME</button>
        <button type="button" class="play-casual-btn">CASUAL MODE</button>
      </div>
      <div class="menu menu--pause">
        <h1>Paused</h1>
        <button type="button" class="resume-btn">RESUME GAME</button>
        <button type="button" class="menu-btn--pause">MAIN MENU</button>
      </div>
      <div class="menu menu--score">
        <h1>Game Over</h1>
        <h2>Your Score:</h2>
        <div class="final-score-lbl"></div>
        <div class="high-score-lbl"></div>
        <button type="button" class="play-again-btn">PLAY AGAIN</button>
        <button type="button" class="menu-btn--score">MAIN MENU</button>
      </div>
    </div>
    <script src="script.js"></script>
  </body>
</html>

CSS :

body {
  margin: 0;
  background-color: #000;
  background-image: radial-gradient(ellipse at top,
      #335476 0%,
      #31506e 11.1%,
      #304b67 22.2%,
      #2f4760 33.3%,
      #2d4359 44.4%,
      #2c3f51 55.6%,
      #2a3a4a 66.7%,
      #293643 77.8%,
      #28323d 88.9%,
      #262e36 100%);
  height: 100vh;
  overflow: hidden;

  font-family: monospace;
  font-weight: bold;
  letter-spacing: 0.06em;
  color: rgba(255, 255, 255, 0.75);
}

#c {
  display: block;
  touch-action: none;
  transform: translateZ(0);
}

.hud__score,
.pause-btn {
  position: fixed;
  font-size: calc(14px + 2vw + 1vh);
}

.hud__score {
  top: 0.65em;
  left: 0.65em;
  pointer-events: none;
  user-select: none;
}

.cube-count-lbl {
  font-size: 0.46em;
}

.pause-btn {
  position: fixed;
  top: 0;
  right: 0;
  padding: 0.8em 0.65em;
}

.pause-btn>div {
  position: relative;
  width: 0.8em;
  height: 0.8em;
  opacity: 0.75;
}

.pause-btn>div::before,
.pause-btn>div::after {
  content: "";
  display: block;
  width: 34%;
  height: 100%;
  position: absolute;
  background-color: #fff;
}

.pause-btn>div::after {
  right: 0;
}

.slowmo {
  position: fixed;
  bottom: 0;
  width: 100%;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.4s;
  will-change: opacity;
}

.slowmo::before {
  content: "SLOW-MO";
  display: block;
  font-size: calc(8px + 1vw + 0.5vh);
  margin-left: 0.5em;
  margin-bottom: 8px;
}

.slowmo::after {
  content: "";
  display: block;
  position: fixed;
  bottom: 0;
  width: 100%;
  height: 1.5vh;
  background-color: rgba(0, 0, 0, 0.25);
  z-index: -1;
}

.slowmo__bar {
  height: 1.5vh;
  background-color: rgba(255, 255, 255, 0.75);
  transform-origin: 0 0;
}

/*/////////////////////
//       MENUS       //
/////////////////////*/

.menus::before {
  content: "";
  pointer-events: none;
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  background-color: #000;
  opacity: 0;
  transition: opacity 0.2s;
  transition-timing-function: ease-in;
}

.menus.has-active::before {
  opacity: 0.08;
  transition-duration: 0.4s;
  transition-timing-function: ease-out;
}

.menus.interactive-mode::before {
  opacity: 0.02;
}

/* Menu containers */
.menu {
  pointer-events: none;
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  user-select: none;
  text-align: center;
  color: rgba(255, 255, 255, 0.9);
  opacity: 0;
  visibility: hidden;
  transform: translateY(30px);
  transition-property: opacity, visibility, transform;
  transition-duration: 0.2s;
  transition-timing-function: ease-in;
}

.menu.active {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
  transition-duration: 0.4s;
  transition-timing-function: ease-out;
}

.menus.interactive-mode .menu.active {
  opacity: 0.6;
}

.menus:not(.interactive-mode) .menu.active>* {
  pointer-events: auto;
}

/* Common menu elements */

h1 {
  font-size: 4rem;
  line-height: 0.95;
  text-align: center;
  font-weight: bold;
  margin: 0 0.65em 1em;
}

h2 {
  font-size: 1.2rem;
  line-height: 1;
  text-align: center;
  font-weight: bold;
  margin: -1em 0.65em 1em;
}

.final-score-lbl {
  font-size: 5rem;
  margin: -0.2em 0 0;
}

.high-score-lbl {
  font-size: 1.2rem;
  margin: 0 0 2.5em;
}

button {
  display: block;
  position: relative;
  width: 200px;
  padding: 12px 20px;
  background: transparent;
  border: none;
  outline: none;
  user-select: none;
  font-family: monospace;
  font-weight: bold;
  font-size: 1.4rem;
  color: #fff;
  opacity: 0.75;
  transition: opacity 0.3s;
}

button::before {
  content: "";
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  background-color: rgba(255, 255, 255, 0.15);
  transform: scale(0, 0);
  opacity: 0;
  transition: opacity 0.3s, transform 0.3s;
}

/* No `:focus` styles because this is a mouse/touch game! */
button:active {
  opacity: 1;
}

button:active::before {
  transform: scale(1, 1);
  opacity: 1;
}

.credits {
  position: fixed;
  width: 100%;
  left: 0;
  bottom: 20px;
}

a {
  color: white;
}

/* Only enable hover state on large screens */
@media (min-width: 1025px) {
  button:hover {
    opacity: 1;
  }

  button:hover::before {
    transform: scale(1, 1);
    opacity: 1;
  }
}

JS :

// globalConfig.js
// ============================================================================
// ============================================================================

// Provides global variables used by the entire program.
// Most of this should be configuration.

// Timing multiplier for entire game engine.
let gameSpeed = 1;

// Colors
const BLUE = { r: 0x67, g: 0xd7, b: 0xf0 };
const GREEN = { r: 0xa6, g: 0xe0, b: 0x2c };
const PINK = { r: 0xfa, g: 0x24, b: 0x73 };
const ORANGE = { r: 0xfe, g: 0x95, b: 0x22 };
const allColors = [BLUE, GREEN, PINK, ORANGE];

// Gameplay
const getSpawnDelay = () => {
  const spawnDelayMax = 1400;
  const spawnDelayMin = 550;
  const spawnDelay = spawnDelayMax - state.game.cubeCount * 3.1;
  return Math.max(spawnDelay, spawnDelayMin);
};
const doubleStrongEnableScore = 2000;
// Number of cubes that must be smashed before activating a feature.
const slowmoThreshold = 10;
const strongThreshold = 25;
const spinnerThreshold = 25;

// Interaction state
let pointerIsDown = false;
// The last known position of the primary pointer in screen coordinates.`
let pointerScreen = { x: 0, y: 0 };
// Same as `pointerScreen`, but converted to scene coordinates in rAF.
let pointerScene = { x: 0, y: 0 };
// Minimum speed of pointer before "hits" are counted.
const minPointerSpeed = 60;
// The hit speed affects the direction the target post-hit. This number dampens that force.
const hitDampening = 0.1;
// Backboard receives shadows and is the farthest negative Z position of entities.
const backboardZ = -400;
const shadowColor = "#262e36";
// How much air drag is applied to standard objects
const airDrag = 0.022;
const gravity = 0.3;
// Spark config
const sparkColor = "rgba(170,221,255,.9)";
const sparkThickness = 2.2;
const airDragSpark = 0.1;
// Track pointer positions to show trail
const touchTrailColor = "rgba(170,221,255,.62)";
const touchTrailThickness = 7;
const touchPointLife = 120;
const touchPoints = [];
// Size of in-game targets. This affects rendered size and hit area.
const targetRadius = 40;
const targetHitRadius = 50;
const makeTargetGlueColor = (target) => {
  // const alpha = (target.health - 1) / (target.maxHealth - 1);
  // return `rgba(170,221,255,${alpha.toFixed(3)})`;
  return "rgb(170,221,255)";
};
// Size of target fragments
const fragRadius = targetRadius / 3;

// Game canvas element needed in setup.js and interaction.js
const canvas = document.querySelector("#c");

// 3D camera config
// Affects perspective
const cameraDistance = 900;
// Does not affect perspective
const sceneScale = 1;
// Objects that get too close to the camera will be faded out to transparent over this range.
// const cameraFadeStartZ = 0.8*cameraDistance - 6*targetRadius;
const cameraFadeStartZ = 0.45 * cameraDistance;
const cameraFadeEndZ = 0.65 * cameraDistance;
const cameraFadeRange = cameraFadeEndZ - cameraFadeStartZ;

// Globals used to accumlate all vertices/polygons in each frame
const allVertices = [];
const allPolys = [];
const allShadowVertices = [];
const allShadowPolys = [];

// state.js
// ============================================================================
// ============================================================================

///////////
// Enums //
///////////

// Game Modes
const GAME_MODE_RANKED = Symbol("GAME_MODE_RANKED");
const GAME_MODE_CASUAL = Symbol("GAME_MODE_CASUAL");

// Available Menus
const MENU_MAIN = Symbol("MENU_MAIN");
const MENU_PAUSE = Symbol("MENU_PAUSE");
const MENU_SCORE = Symbol("MENU_SCORE");

//////////////////
// Global State //
//////////////////

const state = {
  game: {
    mode: GAME_MODE_RANKED,
    // Run time of current game.
    time: 0,
    // Player score.
    score: 0,
    // Total number of cubes smashed in game.
    cubeCount: 0,
  },
  menus: {
    // Set to `null` to hide all menus
    active: MENU_MAIN,
  },
};

////////////////////////////
// Global State Selectors //
////////////////////////////

const isInGame = () => !state.menus.active;
const isMenuVisible = () => !!state.menus.active;
const isCasualGame = () => state.game.mode === GAME_MODE_CASUAL;
const isPaused = () => state.menus.active === MENU_PAUSE;

///////////////////
// Local Storage //
///////////////////

const highScoreKey = "__menja__highScore";
const getHighScore = () => {
  const raw = localStorage.getItem(highScoreKey);
  return raw ? parseInt(raw, 10) : 0;
};

let _lastHighscore = getHighScore();
const setHighScore = (score) => {
  _lastHighscore = getHighScore();
  localStorage.setItem(highScoreKey, String(score));
};

const isNewHighScore = () => state.game.score > _lastHighscore;

// utils.js
// ============================================================================
// ============================================================================

const invariant = (condition, message) => {
  if (!condition) throw new Error(message);
};

/////////
// DOM //
/////////

const $ = (selector) => document.querySelector(selector);
const handleClick = (element, handler) =>
  element.addEventListener("click", handler);
const handlePointerDown = (element, handler) => {
  element.addEventListener("touchstart", handler);
  element.addEventListener("mousedown", handler);
};

////////////////////////
// Formatting Helpers //
////////////////////////

// Converts a number into a formatted string with thousand separators.
const formatNumber = (num) => num.toLocaleString();

////////////////////
// Math Constants //
////////////////////

const PI = Math.PI;
const TAU = Math.PI * 2;
const ETA = Math.PI * 0.5;

//////////////////
// Math Helpers //
//////////////////

// Clamps a number between min and max values (inclusive)
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);

// Linearly interpolate between numbers a and b by a specific amount.
// mix >= 0 && mix <= 1
const lerp = (a, b, mix) => (b - a) * mix + a;

////////////////////
// Random Helpers //
////////////////////

// Generates a random number between min (inclusive) and max (exclusive)
const random = (min, max) => Math.random() * (max - min) + min;

// Generates a random integer between and possibly including min and max values
const randomInt = (min, max) => ((Math.random() * (max - min + 1)) | 0) + min;

// Returns a random element from an array
const pickOne = (arr) => arr[(Math.random() * arr.length) | 0];

///////////////////
// Color Helpers //
///////////////////

// Converts an { r, g, b } color object to a 6-digit hex code.
const colorToHex = (color) => {
  return (
    "#" +
    (color.r | 0).toString(16).padStart(2, "0") +
    (color.g | 0).toString(16).padStart(2, "0") +
    (color.b | 0).toString(16).padStart(2, "0")
  );
};

// Operates on an { r, g, b } color object.
// Returns string hex code.
// `lightness` must range from 0 to 1. 0 is pure black, 1 is pure white.
const shadeColor = (color, lightness) => {
  let other, mix;
  if (lightness < 0.5) {
    other = 0;
    mix = 1 - lightness * 2;
  } else {
    other = 255;
    mix = lightness * 2 - 1;
  }
  return (
    "#" +
    (lerp(color.r, other, mix) | 0).toString(16).padStart(2, "0") +
    (lerp(color.g, other, mix) | 0).toString(16).padStart(2, "0") +
    (lerp(color.b, other, mix) | 0).toString(16).padStart(2, "0")
  );
};

////////////////////
// Timing Helpers //
////////////////////

const _allCooldowns = [];

const makeCooldown = (rechargeTime, units = 1) => {
  let timeRemaining = 0;
  let lastTime = 0;

  const initialOptions = { rechargeTime, units };

  const updateTime = () => {
    const now = state.game.time;
    // Reset time remaining if time goes backwards.
    if (now < lastTime) {
      timeRemaining = 0;
    } else {
      // update...
      timeRemaining -= now - lastTime;
      if (timeRemaining < 0) timeRemaining = 0;
    }
    lastTime = now;
  };

  const canUse = () => {
    updateTime();
    return timeRemaining <= rechargeTime * (units - 1);
  };

  const cooldown = {
    canUse,
    useIfAble() {
      const usable = canUse();
      if (usable) timeRemaining += rechargeTime;
      return usable;
    },
    mutate(options) {
      if (options.rechargeTime) {
        // Apply recharge time delta so change takes effect immediately.
        timeRemaining -= rechargeTime - options.rechargeTime;
        if (timeRemaining < 0) timeRemaining = 0;
        rechargeTime = options.rechargeTime;
      }
      if (options.units) units = options.units;
    },
    reset() {
      timeRemaining = 0;
      lastTime = 0;
      this.mutate(initialOptions);
    },
  };

  _allCooldowns.push(cooldown);

  return cooldown;
};

const resetAllCooldowns = () =>
  _allCooldowns.forEach((cooldown) => cooldown.reset());

const makeSpawner = ({ chance, cooldownPerSpawn, maxSpawns }) => {
  const cooldown = makeCooldown(cooldownPerSpawn, maxSpawns);
  return {
    shouldSpawn() {
      return Math.random() <= chance && cooldown.useIfAble();
    },
    mutate(options) {
      if (options.chance) chance = options.chance;
      cooldown.mutate({
        rechargeTime: options.cooldownPerSpawn,
        units: options.maxSpawns,
      });
    },
  };
};

////////////////////
// Vector Helpers //
////////////////////

const normalize = (v) => {
  const mag = Math.hypot(v.x, v.y, v.z);
  return {
    x: v.x / mag,
    y: v.y / mag,
    z: v.z / mag,
  };
};

// Curried math helpers
const add = (a) => (b) => a + b;
// Curried vector helpers
const scaleVector = (scale) => (vector) => {
  vector.x *= scale;
  vector.y *= scale;
  vector.z *= scale;
};

////////////////
// 3D Helpers //
////////////////

// Clone array and all vertices.
function cloneVertices(vertices) {
  return vertices.map((v) => ({ x: v.x, y: v.y, z: v.z }));
}

// Copy vertex data from one array into another.
// Arrays must be the same length.
function copyVerticesTo(arr1, arr2) {
  const len = arr1.length;
  for (let i = 0; i < len; i++) {
    const v1 = arr1[i];
    const v2 = arr2[i];
    v2.x = v1.x;
    v2.y = v1.y;
    v2.z = v1.z;
  }
}

// Compute triangle midpoint.
// Mutates `middle` property of given `poly`.
function computeTriMiddle(poly) {
  const v = poly.vertices;
  poly.middle.x = (v[0].x + v[1].x + v[2].x) / 3;
  poly.middle.y = (v[0].y + v[1].y + v[2].y) / 3;
  poly.middle.z = (v[0].z + v[1].z + v[2].z) / 3;
}

// Compute quad midpoint.
// Mutates `middle` property of given `poly`.
function computeQuadMiddle(poly) {
  const v = poly.vertices;
  poly.middle.x = (v[0].x + v[1].x + v[2].x + v[3].x) / 4;
  poly.middle.y = (v[0].y + v[1].y + v[2].y + v[3].y) / 4;
  poly.middle.z = (v[0].z + v[1].z + v[2].z + v[3].z) / 4;
}

function computePolyMiddle(poly) {
  if (poly.vertices.length === 3) {
    computeTriMiddle(poly);
  } else {
    computeQuadMiddle(poly);
  }
}

// Compute distance from any polygon (tri or quad) midpoint to camera.
// Sets `depth` property of given `poly`.
// Also triggers midpoint calculation, which mutates `middle` property of `poly`.
function computePolyDepth(poly) {
  computePolyMiddle(poly);
  const dX = poly.middle.x;
  const dY = poly.middle.y;
  const dZ = poly.middle.z - cameraDistance;
  poly.depth = Math.hypot(dX, dY, dZ);
}

// Compute normal of any polygon. Uses normalized vector cross product.
// Mutates `normalName` property of given `poly`.
function computePolyNormal(poly, normalName) {
  // Store quick refs to vertices
  const v1 = poly.vertices[0];
  const v2 = poly.vertices[1];
  const v3 = poly.vertices[2];
  // Calculate difference of vertices, following winding order.
  const ax = v1.x - v2.x;
  const ay = v1.y - v2.y;
  const az = v1.z - v2.z;
  const bx = v1.x - v3.x;
  const by = v1.y - v3.y;
  const bz = v1.z - v3.z;
  // Cross product
  const nx = ay * bz - az * by;
  const ny = az * bx - ax * bz;
  const nz = ax * by - ay * bx;
  // Compute magnitude of normal and normalize
  const mag = Math.hypot(nx, ny, nz);
  const polyNormal = poly[normalName];
  polyNormal.x = nx / mag;
  polyNormal.y = ny / mag;
  polyNormal.z = nz / mag;
}

// Apply translation/rotation/scale to all given vertices.
// If `vertices` and `target` are the same array, the vertices will be mutated in place.
// If `vertices` and `target` are different arrays, `vertices` will not be touched, instead the
// transformed values from `vertices` will be written to `target` array.
function transformVertices(
  vertices,
  target,
  tX,
  tY,
  tZ,
  rX,
  rY,
  rZ,
  sX,
  sY,
  sZ
) {
  // Matrix multiplcation constants only need calculated once for all vertices.
  const sinX = Math.sin(rX);
  const cosX = Math.cos(rX);
  const sinY = Math.sin(rY);
  const cosY = Math.cos(rY);
  const sinZ = Math.sin(rZ);
  const cosZ = Math.cos(rZ);

  // Using forEach() like map(), but with a (recycled) target array.
  vertices.forEach((v, i) => {
    const targetVertex = target[i];
    // X axis rotation
    const x1 = v.x;
    const y1 = v.z * sinX + v.y * cosX;
    const z1 = v.z * cosX - v.y * sinX;
    // Y axis rotation
    const x2 = x1 * cosY - z1 * sinY;
    const y2 = y1;
    const z2 = x1 * sinY + z1 * cosY;
    // Z axis rotation
    const x3 = x2 * cosZ - y2 * sinZ;
    const y3 = x2 * sinZ + y2 * cosZ;
    const z3 = z2;

    // Scale, Translate, and set the transform.
    targetVertex.x = x3 * sX + tX;
    targetVertex.y = y3 * sY + tY;
    targetVertex.z = z3 * sZ + tZ;
  });
}

// 3D projection on a single vertex.
// Directly mutates the vertex.
const projectVertex = (v) => {
  const focalLength = cameraDistance * sceneScale;
  const depth = focalLength / (cameraDistance - v.z);
  v.x = v.x * depth;
  v.y = v.y * depth;
};

// 3D projection on a single vertex.
// Mutates a secondary target vertex.
const projectVertexTo = (v, target) => {
  const focalLength = cameraDistance * sceneScale;
  const depth = focalLength / (cameraDistance - v.z);
  target.x = v.x * depth;
  target.y = v.y * depth;
};

// PERF.js
// ============================================================================
// ============================================================================

// Dummy no-op functions.
// I use these in a special build for custom performance profiling.
const PERF_START = () => {};
const PERF_END = () => {};
const PERF_UPDATE = () => {};

// 3dModels.js
// ============================================================================
// ============================================================================

// Define models once. The origin is the center of the model.

// A simple cube, 8 vertices, 6 quads.
// Defaults to an edge length of 2 units, can be influenced with `scale`.
function makeCubeModel({ scale = 1 }) {
  return {
    vertices: [
      // top
      { x: -scale, y: -scale, z: scale },
      { x: scale, y: -scale, z: scale },
      { x: scale, y: scale, z: scale },
      { x: -scale, y: scale, z: scale },
      // bottom
      { x: -scale, y: -scale, z: -scale },
      { x: scale, y: -scale, z: -scale },
      { x: scale, y: scale, z: -scale },
      { x: -scale, y: scale, z: -scale },
    ],
    polys: [
      // z = 1
      { vIndexes: [0, 1, 2, 3] },
      // z = -1
      { vIndexes: [7, 6, 5, 4] },
      // y = 1
      { vIndexes: [3, 2, 6, 7] },
      // y = -1
      { vIndexes: [4, 5, 1, 0] },
      // x = 1
      { vIndexes: [5, 6, 2, 1] },
      // x = -1
      { vIndexes: [0, 3, 7, 4] },
    ],
  };
}

// Not very optimized - lots of duplicate vertices are generated.
function makeRecursiveCubeModel({ recursionLevel, splitFn, color, scale = 1 }) {
  const getScaleAtLevel = (level) => 1 / 3 ** level;

  // We can model level 0 manually. It's just a single, centered, cube.
  let cubeOrigins = [{ x: 0, y: 0, z: 0 }];

  // Recursively replace cubes with smaller cubes.
  for (let i = 1; i <= recursionLevel; i++) {
    const scale = getScaleAtLevel(i) * 2;
    const cubeOrigins2 = [];
    cubeOrigins.forEach((origin) => {
      cubeOrigins2.push(...splitFn(origin, scale));
    });
    cubeOrigins = cubeOrigins2;
  }

  const finalModel = { vertices: [], polys: [] };

  // Generate single cube model and scale it.
  const cubeModel = makeCubeModel({ scale: 1 });
  cubeModel.vertices.forEach(scaleVector(getScaleAtLevel(recursionLevel)));

  // Compute the max distance x, y, or z origin values will be.
  // Same result as `Math.max(...cubeOrigins.map(o => o.x))`, but much faster.
  const maxComponent =
    getScaleAtLevel(recursionLevel) * (3 ** recursionLevel - 1);

  // Place cube geometry at each origin.
  cubeOrigins.forEach((origin, cubeIndex) => {
    // To compute occlusion (shading), find origin component with greatest
    // magnitude and normalize it relative to `maxComponent`.
    const occlusion =
      Math.max(Math.abs(origin.x), Math.abs(origin.y), Math.abs(origin.z)) /
      maxComponent;
    // At lower iterations, occlusion looks better lightened up a bit.
    const occlusionLighter =
      recursionLevel > 2 ? occlusion : (occlusion + 0.8) / 1.8;
    // Clone, translate vertices to origin, and apply scale
    finalModel.vertices.push(
      ...cubeModel.vertices.map((v) => ({
        x: (v.x + origin.x) * scale,
        y: (v.y + origin.y) * scale,
        z: (v.z + origin.z) * scale,
      }))
    );
    // Clone polys, shift referenced vertex indexes, and compute color.
    finalModel.polys.push(
      ...cubeModel.polys.map((poly) => ({
        vIndexes: poly.vIndexes.map(add(cubeIndex * 8)),
      }))
    );
  });

  return finalModel;
}

// o: Vector3D - Position of cube's origin (center).
// s: Vector3D - Determines size of menger sponge.
function mengerSpongeSplit(o, s) {
  return [
    // Top
    { x: o.x + s, y: o.y - s, z: o.z + s },
    { x: o.x + s, y: o.y - s, z: o.z + 0 },
    { x: o.x + s, y: o.y - s, z: o.z - s },
    { x: o.x + 0, y: o.y - s, z: o.z + s },
    { x: o.x + 0, y: o.y - s, z: o.z - s },
    { x: o.x - s, y: o.y - s, z: o.z + s },
    { x: o.x - s, y: o.y - s, z: o.z + 0 },
    { x: o.x - s, y: o.y - s, z: o.z - s },
    // Bottom
    { x: o.x + s, y: o.y + s, z: o.z + s },
    { x: o.x + s, y: o.y + s, z: o.z + 0 },
    { x: o.x + s, y: o.y + s, z: o.z - s },
    { x: o.x + 0, y: o.y + s, z: o.z + s },
    { x: o.x + 0, y: o.y + s, z: o.z - s },
    { x: o.x - s, y: o.y + s, z: o.z + s },
    { x: o.x - s, y: o.y + s, z: o.z + 0 },
    { x: o.x - s, y: o.y + s, z: o.z - s },
    // Middle
    { x: o.x + s, y: o.y + 0, z: o.z + s },
    { x: o.x + s, y: o.y + 0, z: o.z - s },
    { x: o.x - s, y: o.y + 0, z: o.z + s },
    { x: o.x - s, y: o.y + 0, z: o.z - s },
  ];
}

// Helper to optimize models by merging duplicate vertices within a threshold,
// and removing all polys that share the same vertices.
// Directly mutates the model.
function optimizeModel(model, threshold = 0.0001) {
  const { vertices, polys } = model;

  const compareVertices = (v1, v2) =>
    Math.abs(v1.x - v2.x) < threshold &&
    Math.abs(v1.y - v2.y) < threshold &&
    Math.abs(v1.z - v2.z) < threshold;

  const comparePolys = (p1, p2) => {
    const v1 = p1.vIndexes;
    const v2 = p2.vIndexes;
    return (
      (v1[0] === v2[0] ||
        v1[0] === v2[1] ||
        v1[0] === v2[2] ||
        v1[0] === v2[3]) &&
      (v1[1] === v2[0] ||
        v1[1] === v2[1] ||
        v1[1] === v2[2] ||
        v1[1] === v2[3]) &&
      (v1[2] === v2[0] ||
        v1[2] === v2[1] ||
        v1[2] === v2[2] ||
        v1[2] === v2[3]) &&
      (v1[3] === v2[0] || v1[3] === v2[1] || v1[3] === v2[2] || v1[3] === v2[3])
    );
  };

  vertices.forEach((v, i) => {
    v.originalIndexes = [i];
  });

  for (let i = vertices.length - 1; i >= 0; i--) {
    for (let ii = i - 1; ii >= 0; ii--) {
      const v1 = vertices[i];
      const v2 = vertices[ii];
      if (compareVertices(v1, v2)) {
        vertices.splice(i, 1);
        v2.originalIndexes.push(...v1.originalIndexes);
        break;
      }
    }
  }

  vertices.forEach((v, i) => {
    polys.forEach((p) => {
      p.vIndexes.forEach((vi, ii, arr) => {
        const vo = v.originalIndexes;
        if (vo.includes(vi)) {
          arr[ii] = i;
        }
      });
    });
  });

  polys.forEach((p) => {
    const vi = p.vIndexes;
    p.sum = vi[0] + vi[1] + vi[2] + vi[3];
  });
  polys.sort((a, b) => b.sum - a.sum);

  // Assumptions:
  // 1. Each poly will either have no duplicates or 1 duplicate.
  // 2. If two polys are equal, they are both hidden (two cubes touching),
  //    therefore both can be removed.
  for (let i = polys.length - 1; i >= 0; i--) {
    for (let ii = i - 1; ii >= 0; ii--) {
      const p1 = polys[i];
      const p2 = polys[ii];
      if (p1.sum !== p2.sum) break;
      if (comparePolys(p1, p2)) {
        polys.splice(i, 1);
        polys.splice(ii, 1);
        i--;
        break;
      }
    }
  }

  return model;
}

// Entity.js
// ============================================================================
// ============================================================================

class Entity {
  constructor({ model, color, wireframe = false }) {
    const vertices = cloneVertices(model.vertices);
    const shadowVertices = cloneVertices(model.vertices);
    const colorHex = colorToHex(color);
    const darkColorHex = shadeColor(color, 0.4);

    const polys = model.polys.map((p) => ({
      vertices: p.vIndexes.map((vIndex) => vertices[vIndex]),
      color: color, // custom rgb color object
      wireframe: wireframe,
      strokeWidth: wireframe ? 2 : 0, // Set to non-zero value to draw stroke
      strokeColor: colorHex, // must be a CSS color string
      strokeColorDark: darkColorHex, // must be a CSS color string
      depth: 0,
      middle: { x: 0, y: 0, z: 0 },
      normalWorld: { x: 0, y: 0, z: 0 },
      normalCamera: { x: 0, y: 0, z: 0 },
    }));

    const shadowPolys = model.polys.map((p) => ({
      vertices: p.vIndexes.map((vIndex) => shadowVertices[vIndex]),
      wireframe: wireframe,
      normalWorld: { x: 0, y: 0, z: 0 },
    }));

    this.projected = {}; // Will store 2D projected data
    this.model = model;
    this.vertices = vertices;
    this.polys = polys;
    this.shadowVertices = shadowVertices;
    this.shadowPolys = shadowPolys;
    this.reset();
  }

  // Better names: resetEntity, resetTransform, resetEntityTransform
  reset() {
    this.x = 0;
    this.y = 0;
    this.z = 0;
    this.xD = 0;
    this.yD = 0;
    this.zD = 0;

    this.rotateX = 0;
    this.rotateY = 0;
    this.rotateZ = 0;
    this.rotateXD = 0;
    this.rotateYD = 0;
    this.rotateZD = 0;

    this.scaleX = 1;
    this.scaleY = 1;
    this.scaleZ = 1;

    this.projected.x = 0;
    this.projected.y = 0;
  }

  transform() {
    transformVertices(
      this.model.vertices,
      this.vertices,
      this.x,
      this.y,
      this.z,
      this.rotateX,
      this.rotateY,
      this.rotateZ,
      this.scaleX,
      this.scaleY,
      this.scaleZ
    );

    copyVerticesTo(this.vertices, this.shadowVertices);
  }

  // Projects origin point, stored as `projected` property.
  project() {
    projectVertexTo(this, this.projected);
  }
}

// getTarget.js
// ============================================================================
// ============================================================================

// All active targets
const targets = [];

// Pool target instances by color, using a Map.
// keys are color objects, and values are arrays of targets.
// Also pool wireframe instances separately.
const targetPool = new Map(allColors.map((c) => [c, []]));
const targetWireframePool = new Map(allColors.map((c) => [c, []]));

const getTarget = (() => {
  const slowmoSpawner = makeSpawner({
    chance: 0.5,
    cooldownPerSpawn: 10000,
    maxSpawns: 1,
  });

  let doubleStrong = false;
  const strongSpawner = makeSpawner({
    chance: 0.3,
    cooldownPerSpawn: 12000,
    maxSpawns: 1,
  });

  const spinnerSpawner = makeSpawner({
    chance: 0.1,
    cooldownPerSpawn: 10000,
    maxSpawns: 1,
  });

  // Cached array instances, no need to allocate every time.
  const axisOptions = [
    ["x", "y"],
    ["y", "z"],
    ["z", "x"],
  ];

  function getTargetOfStyle(color, wireframe) {
    const pool = wireframe ? targetWireframePool : targetPool;
    let target = pool.get(color).pop();
    if (!target) {
      target = new Entity({
        model: optimizeModel(
          makeRecursiveCubeModel({
            recursionLevel: 1,
            splitFn: mengerSpongeSplit,
            scale: targetRadius,
          })
        ),
        color: color,
        wireframe: wireframe,
      });

      // Init any properties that will be used.
      // These will not be automatically reset when recycled.
      target.color = color;
      target.wireframe = wireframe;
      // Some properties don't have their final value yet.
      // Initialize with any value of the right type.
      target.hit = false;
      target.maxHealth = 0;
      target.health = 0;
    }
    return target;
  }

  return function getTarget() {
    if (doubleStrong && state.game.score <= doubleStrongEnableScore) {
      doubleStrong = false;
      // Spawner is reset automatically when game resets.
    } else if (!doubleStrong && state.game.score > doubleStrongEnableScore) {
      doubleStrong = true;
      strongSpawner.mutate({ maxSpawns: 2 });
    }

    // Target Parameters
    // --------------------------------
    let color = pickOne([BLUE, GREEN, ORANGE]);
    let wireframe = false;
    let health = 1;
    let maxHealth = 3;
    const spinner =
      state.game.cubeCount >= spinnerThreshold &&
      isInGame() &&
      spinnerSpawner.shouldSpawn();

    // Target Parameter Overrides
    // --------------------------------
    if (
      state.game.cubeCount >= slowmoThreshold &&
      slowmoSpawner.shouldSpawn()
    ) {
      color = BLUE;
      wireframe = true;
    } else if (
      state.game.cubeCount >= strongThreshold &&
      strongSpawner.shouldSpawn()
    ) {
      color = PINK;
      health = 3;
    }

    // Target Creation
    // --------------------------------
    const target = getTargetOfStyle(color, wireframe);
    target.hit = false;
    target.maxHealth = maxHealth;
    target.health = health;
    updateTargetHealth(target, 0);

    const spinSpeeds = [Math.random() * 0.1 - 0.05, Math.random() * 0.1 - 0.05];

    if (spinner) {
      // Ends up spinning a random axis
      spinSpeeds[0] = -0.25;
      spinSpeeds[1] = 0;
      target.rotateZ = random(0, TAU);
    }

    const axes = pickOne(axisOptions);

    spinSpeeds.forEach((spinSpeed, i) => {
      switch (axes[i]) {
        case "x":
          target.rotateXD = spinSpeed;
          break;
        case "y":
          target.rotateYD = spinSpeed;
          break;
        case "z":
          target.rotateZD = spinSpeed;
          break;
      }
    });

    return target;
  };
})();

const updateTargetHealth = (target, healthDelta) => {
  target.health += healthDelta;
  // Only update stroke on non-wireframe targets.
  // Showing "glue" is a temporary attempt to display health. For now, there's
  // no reason to have wireframe targets with high health, so we're fine.
  if (!target.wireframe) {
    const strokeWidth = target.health - 1;
    const strokeColor = makeTargetGlueColor(target);
    for (let p of target.polys) {
      p.strokeWidth = strokeWidth;
      p.strokeColor = strokeColor;
    }
  }
};

const returnTarget = (target) => {
  target.reset();
  const pool = target.wireframe ? targetWireframePool : targetPool;
  pool.get(target.color).push(target);
};

function resetAllTargets() {
  while (targets.length) {
    returnTarget(targets.pop());
  }
}

// createBurst.js
// ============================================================================
// ============================================================================

// Track all active fragments
const frags = [];
// Pool inactive fragments by color, using a Map.
// keys are color objects, and values are arrays of fragments.
// // Also pool wireframe instances separately.
const fragPool = new Map(allColors.map((c) => [c, []]));
const fragWireframePool = new Map(allColors.map((c) => [c, []]));

const createBurst = (() => {
  // Precompute some private data to be reused for all bursts.
  const basePositions = mengerSpongeSplit({ x: 0, y: 0, z: 0 }, fragRadius * 2);
  const positions = cloneVertices(basePositions);
  const prevPositions = cloneVertices(basePositions);
  const velocities = cloneVertices(basePositions);

  const basePositionNormals = basePositions.map(normalize);
  const positionNormals = cloneVertices(basePositionNormals);

  const fragCount = basePositions.length;

  function getFragForTarget(target) {
    const pool = target.wireframe ? fragWireframePool : fragPool;
    let frag = pool.get(target.color).pop();
    if (!frag) {
      frag = new Entity({
        model: makeCubeModel({ scale: fragRadius }),
        color: target.color,
        wireframe: target.wireframe,
      });
      frag.color = target.color;
      frag.wireframe = target.wireframe;
    }
    return frag;
  }

  return (target, force = 1) => {
    // Calculate fragment positions, and what would have been the previous positions
    // when still a part of the larger target.
    transformVertices(
      basePositions,
      positions,
      target.x,
      target.y,
      target.z,
      target.rotateX,
      target.rotateY,
      target.rotateZ,
      1,
      1,
      1
    );
    transformVertices(
      basePositions,
      prevPositions,
      target.x - target.xD,
      target.y - target.yD,
      target.z - target.zD,
      target.rotateX - target.rotateXD,
      target.rotateY - target.rotateYD,
      target.rotateZ - target.rotateZD,
      1,
      1,
      1
    );

    // Compute velocity of each fragment, based on previous positions.
    // Will write to `velocities` array.
    for (let i = 0; i < fragCount; i++) {
      const position = positions[i];
      const prevPosition = prevPositions[i];
      const velocity = velocities[i];

      velocity.x = position.x - prevPosition.x;
      velocity.y = position.y - prevPosition.y;
      velocity.z = position.z - prevPosition.z;
    }

    // Apply target rotation to normals
    transformVertices(
      basePositionNormals,
      positionNormals,
      0,
      0,
      0,
      target.rotateX,
      target.rotateY,
      target.rotateZ,
      1,
      1,
      1
    );

    for (let i = 0; i < fragCount; i++) {
      const position = positions[i];
      const velocity = velocities[i];
      const normal = positionNormals[i];

      const frag = getFragForTarget(target);

      frag.x = position.x;
      frag.y = position.y;
      frag.z = position.z;
      frag.rotateX = target.rotateX;
      frag.rotateY = target.rotateY;
      frag.rotateZ = target.rotateZ;

      const burstSpeed = 2 * force;
      const randSpeed = 2 * force;
      const rotateScale = 0.015;
      frag.xD = velocity.x + normal.x * burstSpeed + Math.random() * randSpeed;
      frag.yD = velocity.y + normal.y * burstSpeed + Math.random() * randSpeed;
      frag.zD = velocity.z + normal.z * burstSpeed + Math.random() * randSpeed;
      frag.rotateXD = frag.xD * rotateScale;
      frag.rotateYD = frag.yD * rotateScale;
      frag.rotateZD = frag.zD * rotateScale;

      frags.push(frag);
    }
  };
})();

const returnFrag = (frag) => {
  frag.reset();
  const pool = frag.wireframe ? fragWireframePool : fragPool;
  pool.get(frag.color).push(frag);
};

// sparks.js
// ============================================================================
// ============================================================================

const sparks = [];
const sparkPool = [];

function addSpark(x, y, xD, yD) {
  const spark = sparkPool.pop() || {};

  spark.x = x + xD * 0.5;
  spark.y = y + yD * 0.5;
  spark.xD = xD;
  spark.yD = yD;
  spark.life = random(200, 300);
  spark.maxLife = spark.life;

  sparks.push(spark);

  return spark;
}

// Spherical spark burst
function sparkBurst(x, y, count, maxSpeed) {
  const angleInc = TAU / count;
  for (let i = 0; i < count; i++) {
    const angle = i * angleInc + angleInc * Math.random();
    const speed = (1 - Math.random() ** 3) * maxSpeed;
    addSpark(x, y, Math.sin(angle) * speed, Math.cos(angle) * speed);
  }
}

// Make a target "leak" sparks from all vertices.
// This is used to create the effect of target glue "shedding".
let glueShedVertices;
function glueShedSparks(target) {
  if (!glueShedVertices) {
    glueShedVertices = cloneVertices(target.vertices);
  } else {
    copyVerticesTo(target.vertices, glueShedVertices);
  }

  glueShedVertices.forEach((v) => {
    if (Math.random() < 0.4) {
      projectVertex(v);
      addSpark(v.x, v.y, random(-12, 12), random(-12, 12));
    }
  });
}

function returnSpark(spark) {
  sparkPool.push(spark);
}

// hud.js
// ============================================================================
// ============================================================================

const hudContainerNode = $(".hud");

function setHudVisibility(visible) {
  if (visible) {
    hudContainerNode.style.display = "block";
  } else {
    hudContainerNode.style.display = "none";
  }
}

///////////
// Score //
///////////
const scoreNode = $(".score-lbl");
const cubeCountNode = $(".cube-count-lbl");

function renderScoreHud() {
  if (isCasualGame()) {
    scoreNode.style.display = "none";
    cubeCountNode.style.opacity = 1;
  } else {
    scoreNode.innerText = `SCORE: ${state.game.score}`;
    scoreNode.style.display = "block";
    cubeCountNode.style.opacity = 0.65;
  }
  cubeCountNode.innerText = `CUBES SMASHED: ${state.game.cubeCount}`;
}

renderScoreHud();

//////////////////
// Pause Button //
//////////////////

handlePointerDown($(".pause-btn"), () => pauseGame());

////////////////////
// Slow-Mo Status //
////////////////////

const slowmoNode = $(".slowmo");
const slowmoBarNode = $(".slowmo__bar");

function renderSlowmoStatus(percentRemaining) {
  slowmoNode.style.opacity = percentRemaining === 0 ? 0 : 1;
  slowmoBarNode.style.transform = `scaleX(${percentRemaining.toFixed(3)})`;
}

// menus.js
// ============================================================================
// ============================================================================

// Top-level menu containers
const menuContainerNode = $(".menus");
const menuMainNode = $(".menu--main");
const menuPauseNode = $(".menu--pause");
const menuScoreNode = $(".menu--score");

const finalScoreLblNode = $(".final-score-lbl");
const highScoreLblNode = $(".high-score-lbl");

function showMenu(node) {
  node.classList.add("active");
}

function hideMenu(node) {
  node.classList.remove("active");
}

function renderMenus() {
  hideMenu(menuMainNode);
  hideMenu(menuPauseNode);
  hideMenu(menuScoreNode);

  switch (state.menus.active) {
    case MENU_MAIN:
      showMenu(menuMainNode);
      break;
    case MENU_PAUSE:
      showMenu(menuPauseNode);
      break;
    case MENU_SCORE:
      finalScoreLblNode.textContent = formatNumber(state.game.score);
      if (isNewHighScore()) {
        highScoreLblNode.textContent = "New High Score!";
      } else {
        highScoreLblNode.textContent = `High Score: ${formatNumber(
          getHighScore()
        )}`;
      }
      showMenu(menuScoreNode);
      break;
  }

  setHudVisibility(!isMenuVisible());
  menuContainerNode.classList.toggle("has-active", isMenuVisible());
  menuContainerNode.classList.toggle(
    "interactive-mode",
    isMenuVisible() && pointerIsDown
  );
}

renderMenus();

////////////////////
// Button Actions //
////////////////////

// Main Menu
handleClick($(".play-normal-btn"), () => {
  setGameMode(GAME_MODE_RANKED);
  setActiveMenu(null);
  resetGame();
});

handleClick($(".play-casual-btn"), () => {
  setGameMode(GAME_MODE_CASUAL);
  setActiveMenu(null);
  resetGame();
});

// Pause Menu
handleClick($(".resume-btn"), () => resumeGame());
handleClick($(".menu-btn--pause"), () => setActiveMenu(MENU_MAIN));

// Score Menu
handleClick($(".play-again-btn"), () => {
  setActiveMenu(null);
  resetGame();
});

handleClick($(".menu-btn--score"), () => setActiveMenu(MENU_MAIN));

////////////////////
// Button Actions //
////////////////////

// Main Menu
handleClick($(".play-normal-btn"), () => {
  setGameMode(GAME_MODE_RANKED);
  setActiveMenu(null);
  resetGame();
});

handleClick($(".play-casual-btn"), () => {
  setGameMode(GAME_MODE_CASUAL);
  setActiveMenu(null);
  resetGame();
});

// Pause Menu
handleClick($(".resume-btn"), () => resumeGame());
handleClick($(".menu-btn--pause"), () => setActiveMenu(MENU_MAIN));

// Score Menu
handleClick($(".play-again-btn"), () => {
  setActiveMenu(null);
  resetGame();
});

handleClick($(".menu-btn--score"), () => setActiveMenu(MENU_MAIN));

// actions.js
// ============================================================================
// ============================================================================

//////////////////
// MENU ACTIONS //
//////////////////

function setActiveMenu(menu) {
  state.menus.active = menu;
  renderMenus();
}

/////////////////
// HUD ACTIONS //
/////////////////

function setScore(score) {
  state.game.score = score;
  renderScoreHud();
}

function incrementScore(inc) {
  if (isInGame()) {
    state.game.score += inc;
    if (state.game.score < 0) {
      state.game.score = 0;
    }
    renderScoreHud();
  }
}

function setCubeCount(count) {
  state.game.cubeCount = count;
  renderScoreHud();
}

function incrementCubeCount(inc) {
  if (isInGame()) {
    state.game.cubeCount += inc;
    renderScoreHud();
  }
}

//////////////////
// GAME ACTIONS //
//////////////////

function setGameMode(mode) {
  state.game.mode = mode;
}

function resetGame() {
  resetAllTargets();
  state.game.time = 0;
  resetAllCooldowns();
  setScore(0);
  setCubeCount(0);
  spawnTime = getSpawnDelay();
}

function pauseGame() {
  isInGame() && setActiveMenu(MENU_PAUSE);
}

function resumeGame() {
  isPaused() && setActiveMenu(null);
}

function endGame() {
  handleCanvasPointerUp();
  if (isNewHighScore()) {
    setHighScore(state.game.score);
  }
  setActiveMenu(MENU_SCORE);
}

////////////////////////
// KEYBOARD SHORTCUTS //
////////////////////////

window.addEventListener("keydown", (event) => {
  if (event.key === "p") {
    isPaused() ? resumeGame() : pauseGame();
  }
});

// tick.js
// ============================================================================
// ============================================================================

let spawnTime = 0;
const maxSpawnX = 450;
const pointerDelta = { x: 0, y: 0 };
const pointerDeltaScaled = { x: 0, y: 0 };

// Temp slowmo state. Should be relocated once this stabilizes.
const slowmoDuration = 1500;
let slowmoRemaining = 0;
let spawnExtra = 0;
const spawnExtraDelay = 300;
let targetSpeed = 1;

function tick(width, height, simTime, simSpeed, lag) {
  PERF_START("frame");
  PERF_START("tick");

  state.game.time += simTime;

  if (slowmoRemaining > 0) {
    slowmoRemaining -= simTime;
    if (slowmoRemaining < 0) {
      slowmoRemaining = 0;
    }
    targetSpeed = pointerIsDown ? 0.075 : 0.3;
  } else {
    const menuPointerDown = isMenuVisible() && pointerIsDown;
    targetSpeed = menuPointerDown ? 0.025 : 1;
  }

  renderSlowmoStatus(slowmoRemaining / slowmoDuration);

  gameSpeed += ((targetSpeed - gameSpeed) / 22) * lag;
  gameSpeed = clamp(gameSpeed, 0, 1);

  const centerX = width / 2;
  const centerY = height / 2;

  const simAirDrag = 1 - airDrag * simSpeed;
  const simAirDragSpark = 1 - airDragSpark * simSpeed;

  // Pointer Tracking
  // -------------------

  // Compute speed and x/y deltas.
  // There is also a "scaled" variant taking game speed into account. This serves two purposes:
  //  - Lag won't create large spikes in speed/deltas
  //  - In slow mo, speed is increased proportionately to match "reality". Without this boost,
  //    it feels like your actions are dampened in slow mo.
  const forceMultiplier = 1 / (simSpeed * 0.75 + 0.25);
  pointerDelta.x = 0;
  pointerDelta.y = 0;
  pointerDeltaScaled.x = 0;
  pointerDeltaScaled.y = 0;
  const lastPointer = touchPoints[touchPoints.length - 1];

  if (pointerIsDown && lastPointer && !lastPointer.touchBreak) {
    pointerDelta.x = pointerScene.x - lastPointer.x;
    pointerDelta.y = pointerScene.y - lastPointer.y;
    pointerDeltaScaled.x = pointerDelta.x * forceMultiplier;
    pointerDeltaScaled.y = pointerDelta.y * forceMultiplier;
  }
  const pointerSpeed = Math.hypot(pointerDelta.x, pointerDelta.y);
  const pointerSpeedScaled = pointerSpeed * forceMultiplier;

  // Track points for later calculations, including drawing trail.
  touchPoints.forEach((p) => (p.life -= simTime));

  if (pointerIsDown) {
    touchPoints.push({
      x: pointerScene.x,
      y: pointerScene.y,
      life: touchPointLife,
    });
  }

  while (touchPoints[0] && touchPoints[0].life <= 0) {
    touchPoints.shift();
  }

  // Entity Manipulation
  // --------------------
  PERF_START("entities");

  // Spawn targets
  spawnTime -= simTime;
  if (spawnTime <= 0) {
    if (spawnExtra > 0) {
      spawnExtra--;
      spawnTime = spawnExtraDelay;
    } else {
      spawnTime = getSpawnDelay();
    }
    const target = getTarget();
    const spawnRadius = Math.min(centerX * 0.8, maxSpawnX);
    target.x = Math.random() * spawnRadius * 2 - spawnRadius;
    target.y = centerY + targetHitRadius * 2;
    target.z = Math.random() * targetRadius * 2 - targetRadius;
    target.xD = Math.random() * ((target.x * -2) / 120);
    target.yD = -20;
    targets.push(target);
  }

  // Animate targets and remove when offscreen
  const leftBound = -centerX + targetRadius;
  const rightBound = centerX - targetRadius;
  const ceiling = -centerY - 120;
  const boundDamping = 0.4;

  targetLoop: for (let i = targets.length - 1; i >= 0; i--) {
    const target = targets[i];
    target.x += target.xD * simSpeed;
    target.y += target.yD * simSpeed;

    if (target.y < ceiling) {
      target.y = ceiling;
      target.yD = 0;
    }

    if (target.x < leftBound) {
      target.x = leftBound;
      target.xD *= -boundDamping;
    } else if (target.x > rightBound) {
      target.x = rightBound;
      target.xD *= -boundDamping;
    }

    if (target.z < backboardZ) {
      target.z = backboardZ;
      target.zD *= -boundDamping;
    }

    target.yD += gravity * simSpeed;
    target.rotateX += target.rotateXD * simSpeed;
    target.rotateY += target.rotateYD * simSpeed;
    target.rotateZ += target.rotateZD * simSpeed;
    target.transform();
    target.project();

    // Remove if offscreen
    if (target.y > centerY + targetHitRadius * 2) {
      targets.splice(i, 1);
      returnTarget(target);
      if (isInGame()) {
        if (isCasualGame()) {
          incrementScore(-25);
        } else {
          endGame();
        }
      }
      continue;
    }

    // If pointer is moving really fast, we want to hittest multiple points along the path.
    // We can't use scaled pointer speed to determine this, since we care about actual screen
    // distance covered.
    const hitTestCount = Math.ceil((pointerSpeed / targetRadius) * 2);
    // Start loop at `1` and use `<=` check, so we skip 0% and end up at 100%.
    // This omits the previous point position, and includes the most recent.
    for (let ii = 1; ii <= hitTestCount; ii++) {
      const percent = 1 - ii / hitTestCount;
      const hitX = pointerScene.x - pointerDelta.x * percent;
      const hitY = pointerScene.y - pointerDelta.y * percent;
      const distance = Math.hypot(
        hitX - target.projected.x,
        hitY - target.projected.y
      );

      if (distance <= targetHitRadius) {
        // Hit! (though we don't want to allow hits on multiple sequential frames)
        if (!target.hit) {
          target.hit = true;

          target.xD += pointerDeltaScaled.x * hitDampening;
          target.yD += pointerDeltaScaled.y * hitDampening;
          target.rotateXD += pointerDeltaScaled.y * 0.001;
          target.rotateYD += pointerDeltaScaled.x * 0.001;

          const sparkSpeed = 7 + pointerSpeedScaled * 0.125;

          if (pointerSpeedScaled > minPointerSpeed) {
            target.health--;
            incrementScore(10);

            if (target.health <= 0) {
              incrementCubeCount(1);
              createBurst(target, forceMultiplier);
              sparkBurst(hitX, hitY, 8, sparkSpeed);
              if (target.wireframe) {
                slowmoRemaining = slowmoDuration;
                spawnTime = 0;
                spawnExtra = 2;
              }
              targets.splice(i, 1);
              returnTarget(target);
            } else {
              sparkBurst(hitX, hitY, 8, sparkSpeed);
              glueShedSparks(target);
              updateTargetHealth(target, 0);
            }
          } else {
            incrementScore(5);
            sparkBurst(hitX, hitY, 3, sparkSpeed);
          }
        }
        // Break the current loop and continue the outer loop.
        // This skips to processing the next target.
        continue targetLoop;
      }
    }

    // This code will only run if target hasn't been "hit".
    target.hit = false;
  }

  // Animate fragments and remove when offscreen.
  const fragBackboardZ = backboardZ + fragRadius;
  // Allow fragments to move off-screen to sides for a while, since shadows are still visible.
  const fragLeftBound = -width;
  const fragRightBound = width;

  for (let i = frags.length - 1; i >= 0; i--) {
    const frag = frags[i];
    frag.x += frag.xD * simSpeed;
    frag.y += frag.yD * simSpeed;
    frag.z += frag.zD * simSpeed;

    frag.xD *= simAirDrag;
    frag.yD *= simAirDrag;
    frag.zD *= simAirDrag;

    if (frag.y < ceiling) {
      frag.y = ceiling;
      frag.yD = 0;
    }

    if (frag.z < fragBackboardZ) {
      frag.z = fragBackboardZ;
      frag.zD *= -boundDamping;
    }

    frag.yD += gravity * simSpeed;
    frag.rotateX += frag.rotateXD * simSpeed;
    frag.rotateY += frag.rotateYD * simSpeed;
    frag.rotateZ += frag.rotateZD * simSpeed;
    frag.transform();
    frag.project();

    // Removal conditions
    if (
      // Bottom of screen
      frag.projected.y > centerY + targetHitRadius ||
      // Sides of screen
      frag.projected.x < fragLeftBound ||
      frag.projected.x > fragRightBound ||
      // Too close to camera
      frag.z > cameraFadeEndZ
    ) {
      frags.splice(i, 1);
      returnFrag(frag);
      continue;
    }
  }

  // 2D sparks
  for (let i = sparks.length - 1; i >= 0; i--) {
    const spark = sparks[i];
    spark.life -= simTime;
    if (spark.life <= 0) {
      sparks.splice(i, 1);
      returnSpark(spark);
      continue;
    }
    spark.x += spark.xD * simSpeed;
    spark.y += spark.yD * simSpeed;
    spark.xD *= simAirDragSpark;
    spark.yD *= simAirDragSpark;
    spark.yD += gravity * simSpeed;
  }

  PERF_END("entities");

  // 3D transforms
  // -------------------

  PERF_START("3D");

  // Aggregate all scene vertices/polys
  allVertices.length = 0;
  allPolys.length = 0;
  allShadowVertices.length = 0;
  allShadowPolys.length = 0;
  targets.forEach((entity) => {
    allVertices.push(...entity.vertices);
    allPolys.push(...entity.polys);
    allShadowVertices.push(...entity.shadowVertices);
    allShadowPolys.push(...entity.shadowPolys);
  });

  frags.forEach((entity) => {
    allVertices.push(...entity.vertices);
    allPolys.push(...entity.polys);
    allShadowVertices.push(...entity.shadowVertices);
    allShadowPolys.push(...entity.shadowPolys);
  });

  // Scene calculations/transformations
  allPolys.forEach((p) => computePolyNormal(p, "normalWorld"));
  allPolys.forEach(computePolyDepth);
  allPolys.sort((a, b) => b.depth - a.depth);

  // Perspective projection
  allVertices.forEach(projectVertex);

  allPolys.forEach((p) => computePolyNormal(p, "normalCamera"));

  PERF_END("3D");

  PERF_START("shadows");

  // Rotate shadow vertices to light source perspective
  transformVertices(
    allShadowVertices,
    allShadowVertices,
    0,
    0,
    0,
    TAU / 8,
    0,
    0,
    1,
    1,
    1
  );

  allShadowPolys.forEach((p) => computePolyNormal(p, "normalWorld"));

  const shadowDistanceMult = Math.hypot(1, 1);
  const shadowVerticesLength = allShadowVertices.length;
  for (let i = 0; i < shadowVerticesLength; i++) {
    const distance = allVertices[i].z - backboardZ;
    allShadowVertices[i].z -= shadowDistanceMult * distance;
  }
  transformVertices(
    allShadowVertices,
    allShadowVertices,
    0,
    0,
    0,
    -TAU / 8,
    0,
    0,
    1,
    1,
    1
  );
  allShadowVertices.forEach(projectVertex);

  PERF_END("shadows");

  PERF_END("tick");
}

// draw.js
// ============================================================================
// ============================================================================

function draw(ctx, width, height, viewScale) {
  PERF_START("draw");

  const halfW = width / 2;
  const halfH = height / 2;

  // 3D Polys
  // ---------------
  ctx.lineJoin = "bevel";

  PERF_START("drawShadows");
  ctx.fillStyle = shadowColor;
  ctx.strokeStyle = shadowColor;
  allShadowPolys.forEach((p) => {
    if (p.wireframe) {
      ctx.lineWidth = 2;
      ctx.beginPath();
      const { vertices } = p;
      const vCount = vertices.length;
      const firstV = vertices[0];
      ctx.moveTo(firstV.x, firstV.y);
      for (let i = 1; i < vCount; i++) {
        const v = vertices[i];
        ctx.lineTo(v.x, v.y);
      }
      ctx.closePath();
      ctx.stroke();
    } else {
      ctx.beginPath();
      const { vertices } = p;
      const vCount = vertices.length;
      const firstV = vertices[0];
      ctx.moveTo(firstV.x, firstV.y);
      for (let i = 1; i < vCount; i++) {
        const v = vertices[i];
        ctx.lineTo(v.x, v.y);
      }
      ctx.closePath();
      ctx.fill();
    }
  });
  PERF_END("drawShadows");

  PERF_START("drawPolys");

  allPolys.forEach((p) => {
    if (!p.wireframe && p.normalCamera.z < 0) return;

    if (p.strokeWidth !== 0) {
      ctx.lineWidth =
        p.normalCamera.z < 0 ? p.strokeWidth * 0.5 : p.strokeWidth;
      ctx.strokeStyle =
        p.normalCamera.z < 0 ? p.strokeColorDark : p.strokeColor;
    }

    const { vertices } = p;
    const lastV = vertices[vertices.length - 1];
    const fadeOut = p.middle.z > cameraFadeStartZ;

    if (!p.wireframe) {
      const normalLight = p.normalWorld.y * 0.5 + p.normalWorld.z * -0.5;
      const lightness =
        normalLight > 0
          ? 0.1
          : ((normalLight ** 32 - normalLight) / 2) * 0.9 + 0.1;
      ctx.fillStyle = shadeColor(p.color, lightness);
    }

    // Fade out polys close to camera. `globalAlpha` must be reset later.
    if (fadeOut) {
      // If polygon gets really close to camera (outside `cameraFadeRange`) the alpha
      // can go negative, which has the appearance of alpha = 1. So, we'll clamp it at 0.
      ctx.globalAlpha = Math.max(
        0,
        1 - (p.middle.z - cameraFadeStartZ) / cameraFadeRange
      );
    }

    ctx.beginPath();
    ctx.moveTo(lastV.x, lastV.y);
    for (let v of vertices) {
      ctx.lineTo(v.x, v.y);
    }

    if (!p.wireframe) {
      ctx.fill();
    }
    if (p.strokeWidth !== 0) {
      ctx.stroke();
    }

    if (fadeOut) {
      ctx.globalAlpha = 1;
    }
  });
  PERF_END("drawPolys");

  PERF_START("draw2D");

  ctx.strokeStyle = sparkColor;
  ctx.lineWidth = sparkThickness;
  ctx.beginPath();
  sparks.forEach((spark) => {
    ctx.moveTo(spark.x, spark.y);
    const scale = (spark.life / spark.maxLife) ** 0.5 * 1.5;
    ctx.lineTo(spark.x - spark.xD * scale, spark.y - spark.yD * scale);
  });
  ctx.stroke();

  // Touch Strokes
  // ---------------

  ctx.strokeStyle = touchTrailColor;
  const touchPointCount = touchPoints.length;
  for (let i = 1; i < touchPointCount; i++) {
    const current = touchPoints[i];
    const prev = touchPoints[i - 1];
    if (current.touchBreak || prev.touchBreak) {
      continue;
    }
    const scale = current.life / touchPointLife;
    ctx.lineWidth = scale * touchTrailThickness;
    ctx.beginPath();
    ctx.moveTo(prev.x, prev.y);
    ctx.lineTo(current.x, current.y);
    ctx.stroke();
  }

  PERF_END("draw2D");

  PERF_END("draw");
  PERF_END("frame");

  // Display performance updates.
  PERF_UPDATE();
}

function setupCanvases() {
  const ctx = canvas.getContext("2d");
  // devicePixelRatio alias
  const dpr = window.devicePixelRatio || 1;
  // View will be scaled so objects appear sized similarly on all screen sizes.
  let viewScale;
  // Dimensions (taking viewScale into account!)
  let width, height;

  function handleResize() {
    const w = window.innerWidth;
    const h = window.innerHeight;
    viewScale = h / 1000;
    width = w / viewScale;
    height = h / viewScale;
    canvas.width = w * dpr;
    canvas.height = h * dpr;
    canvas.style.width = w + "px";
    canvas.style.height = h + "px";
  }

  handleResize();

  window.addEventListener("resize", handleResize);

  let lastTimestamp = 0;
  function frameHandler(timestamp) {
    let frameTime = timestamp - lastTimestamp;
    lastTimestamp = timestamp;

    raf();

    if (isPaused()) return;

    if (frameTime < 0) {
      frameTime = 17;
    } else if (frameTime > 68) {
      frameTime = 68;
    }

    const halfW = width / 2;
    const halfH = height / 2;
    pointerScene.x = pointerScreen.x / viewScale - halfW;
    pointerScene.y = pointerScreen.y / viewScale - halfH;

    const lag = frameTime / 16.6667;
    const simTime = gameSpeed * frameTime;
    const simSpeed = gameSpeed * lag;
    tick(width, height, simTime, simSpeed, lag);
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    const drawScale = dpr * viewScale;
    ctx.scale(drawScale, drawScale);
    ctx.translate(halfW, halfH);
    draw(ctx, width, height, viewScale);
    ctx.setTransform(1, 0, 0, 1, 0, 0);
  }
  const raf = () => requestAnimationFrame(frameHandler);
  // Start loop
  raf();
}

function handleCanvasPointerDown(x, y) {
  if (!pointerIsDown) {
    pointerIsDown = true;
    pointerScreen.x = x;
    pointerScreen.y = y;
    // On when menus are open, point down/up toggles an interactive mode.
    // We just need to rerender the menu system for it to respond.
    if (isMenuVisible()) renderMenus();
  }
}

function handleCanvasPointerUp() {
  if (pointerIsDown) {
    pointerIsDown = false;
    touchPoints.push({
      touchBreak: true,
      life: touchPointLife,
    });
    // On when menus are open, point down/up toggles an interactive mode.
    // We just need to rerender the menu system for it to respond.
    if (isMenuVisible()) renderMenus();
  }
}

function handleCanvasPointerMove(x, y) {
  if (pointerIsDown) {
    pointerScreen.x = x;
    pointerScreen.y = y;
  }
}

// Use pointer events if available, otherwise fallback to touch events (for iOS).
if ("PointerEvent" in window) {
  canvas.addEventListener("pointerdown", (event) => {
    event.isPrimary && handleCanvasPointerDown(event.clientX, event.clientY);
  });

  canvas.addEventListener("pointerup", (event) => {
    event.isPrimary && handleCanvasPointerUp();
  });

  canvas.addEventListener("pointermove", (event) => {
    event.isPrimary && handleCanvasPointerMove(event.clientX, event.clientY);
  });

  // We also need to know if the mouse leaves the page. For this game, it's best if that
  // cancels a swipe, so essentially acts as a "mouseup" event.
  document.body.addEventListener("mouseleave", handleCanvasPointerUp);
} else {
  let activeTouchId = null;
  canvas.addEventListener("touchstart", (event) => {
    if (!pointerIsDown) {
      const touch = event.changedTouches[0];
      activeTouchId = touch.identifier;
      handleCanvasPointerDown(touch.clientX, touch.clientY);
    }
  });
  canvas.addEventListener("touchend", (event) => {
    for (let touch of event.changedTouches) {
      if (touch.identifier === activeTouchId) {
        handleCanvasPointerUp();
        break;
      }
    }
  });
  canvas.addEventListener(
    "touchmove",
    (event) => {
      for (let touch of event.changedTouches) {
        if (touch.identifier === activeTouchId) {
          handleCanvasPointerMove(touch.clientX, touch.clientY);
          event.preventDefault();
          break;
        }
      }
    },
    { passive: false }
  );
}

setupCanvases();
 

Source Code