@import url('https://fonts.googleapis.com/css?family=Hind');

*, *::before, *::after {
	box-sizing: border-box;
}

html, body {
	height: 100%;
	overflow: hidden;
}

body {
	display: flex;
	justify-content: center;
	align-items: center;
	margin: 0;
	color: #dee2e6;
	background-color: #212529;
	font-family: 'Hind', sans-serif;
	text-transform: uppercase;
}

.container {
	position: relative;
	line-height: 0;
}

.score {
	position: absolute;
	top: 10px;
	line-height: 1;
}

.score span {
	font-size: 3.25rem;
}

.current-score {
	left: 10px;
}

.high-score {
	text-align: right;
	right: 10px;
}

.button {
  -webkit-appearance: none;
     -moz-appearance: none;
          appearance: none;
	position: absolute;
  top: 10px;
	width: 80px;
	height: 80px;
  border: 0;
	border-radius: 50%;
  color: inherit;
	background-color: #e64980;
	text-align: center;
	line-height: 80px;
	font-size: 1.25rem;
  text-transform: inherit;
	cursor: pointer;
	-webkit-user-select: none;
	   -moz-user-select: none;
	    -ms-user-select: none;
	        user-select: none;
  outline: none;
}

.button:disabled,
.button[disabled] {
  color: #aaaaaa;
	background-color: #cccccc;
  cursor: default;
}

.button-start {
	left: 100px;
}

.button-reset {
	right: 100px;
}

canvas {
	overflow: hidden;
	border-radius: 5px;
	box-shadow: 0 5px 25px rgba(0, 0, 0, 0.75);
}

/* responsive scaling, because pinball tables are tall */
@media (min-height: 0px) {
	.container { -webkit-transform: scale(0.25); transform: scale(0.25); }
}
@media (min-height: 400px) {
	.container { -webkit-transform: scale(0.5); transform: scale(0.5); }
}
@media (min-height: 600px) {
	.container { -webkit-transform: scale(0.75); transform: scale(0.75); }
}
@media (min-height: 800px) {
	.container { -webkit-transform: scale(1); transform: scale(1); }
}
// http://brm.io/matter-js/docs/index.html

(() => {
  const COLOR = {
    BACKGROUND: "#212529",
    OUTER: "#495057",
    INNER: "#15aabf",
    BUMPER: "#fab005",
    BUMPER_LIT: "#fff3bf",
    PINBALL: "#dee2e6"
  };

  const WORLD_SIZE = { x: 500, y: 2000 };
  const RENDER_SIZE = { x: 500, y: 800 };

  const GRAVITY = 1;
  const WIREFRAMES = false;
  const BUMPER_BOUNCE = 1;
  const MAX_VELOCITY = 30;

  const BUMPER_RADIUS = 10;
  const PINBALL_RADIUS = 20;
  
  // score html elements
  var $currentScore, $highScore;

  // shared variables
  var currentScore, highScore;
  var engine, world, render, pinball, stopperGroup;

  var crane;
  var craneSize = 50;
  var craneMovementOffset = 0;

  var pinballDropped = false;

  var bumperHitArray = Array();

  function load() {
    createHTMLBody();
    $(".button-start").prop("disabled", true);
    $(".button-reset").prop("disabled", true);

    createScene();
    createStaticBodies();
    createEvents();

    $(".button-start").prop("disabled", false);
  }

  function createHTMLBody() {
    // main container
    var container = document.createElement("div");
    container.className = "container";
    document.body.appendChild(container);

    // current score
    var current_score = document.createElement("div");
    current_score.className = "score current-score";

    current_score.appendChild(document.createTextNode("score"));
    current_score.appendChild(document.createElement("br"));
    current_score.appendChild(document.createElement("span"));

    container.appendChild(current_score);

    // high score
    var high_score = document.createElement("div");
    high_score.className = "score high-score";

    high_score.appendChild(document.createTextNode("high score"));
    high_score.appendChild(document.createElement("br"));
    high_score.appendChild(document.createElement("span"));

    container.appendChild(high_score);

    // start button
    var button_start = document.createElement("button");
    button_start.className = "button button-start";
    button_start.appendChild(document.createTextNode("Start"));

    container.appendChild(button_start);

    // reset button
    var button_reset = document.createElement("button");
    button_reset.className = "button button-reset";
    button_reset.appendChild(document.createTextNode("Reset"));

    container.appendChild(button_reset);
  }

  function createScene() {
    // engine (shared)
    engine = Matter.Engine.create();

    // world (shared)
    world = engine.world;
    world.bounds = {
      min: { x: 0, y: 0 },
      max: { x: WORLD_SIZE.x, y: WORLD_SIZE.y }
    };
    world.gravity.y = GRAVITY; // simulate rolling on a slanted table

    // render (shared)
    render = Matter.Render.create({
      element: $(".container")[0],
      engine: engine,
      options: {
        width: RENDER_SIZE.x,
        height: RENDER_SIZE.y,
        background: COLOR.BACKGROUND,
        wireframes: WIREFRAMES,
        //showAngleIndicator: true,
        //showVelocity: true,
        //showCollisions: true,
        hasBounds: true
      }
    });
    Matter.Render.run(render);

    // runner
    var runner = Matter.Runner.create();
    Matter.Runner.run(runner, engine);

    // used for collision filtering on various bodies
    stopperGroup = Matter.Body.nextGroup(true);

    // html elements
    $currentScore = $(".current-score span");
    $highScore = $(".high-score span");

    // starting values
    currentScore = 0;
    highScore = 0;

    updateScore(0);
  }

  function createStaticBodies() {
    Matter.World.add(world, [
      // table boundaries (top, bottom, left, right)
      boundary(
        world.bounds.max.x / 2,
        world.bounds.min.y + 50,
        world.bounds.max.x,
        100
      ),
      boundary(
        world.bounds.max.x / 2,
        world.bounds.max.y - 10,
        world.bounds.max.x,
        20
      ),
      boundary(
        world.bounds.min.x + 10,
        world.bounds.max.y / 2,
        20,
        world.bounds.max.y
      ),
      boundary(
        world.bounds.max.x - 10,
        world.bounds.max.y / 2,
        20,
        world.bounds.max.y
      )
    ]);

    crane = Matter.Bodies.rectangle(world.bounds.max.x / 2, 125, craneSize, craneSize, {
      label: "crane",
      isStatic: true,
      chamfer: 10,
      render: {
        fillStyle: COLOR.INNER
      }
    });
    Matter.World.add(world, crane);

    pinball = Matter.Bodies.circle(crane.position.x, crane.bounds.max.y + PINBALL_RADIUS, PINBALL_RADIUS, {
      label: "pinball",
      isStatic: true,
      collisionFilter: {
        group: stopperGroup
      },
      render: {
        fillStyle: COLOR.PINBALL
      }
    });
    Matter.World.add(world, pinball);

    const max_bumper_per_row = 4;

    const bumper_x_step = 140;
    const bumper_y_step = 140; //90;

    const bumper_x_even = 40;
    const bumper_x_odd = bumper_x_even + bumper_x_step / 2;

    var bumper_y = 250;

    //*
    for (var i = 0; i < 6; i++) {
      // even bumper row
      for (var n = 0; n < max_bumper_per_row; n++) {
        Matter.World.add(
          world,
          bumper(bumper_x_even + n * bumper_x_step, bumper_y)
        );
      }

      bumper_y += bumper_y_step;

      // odd bumper row
      for (var n = 0; n < max_bumper_per_row - 1; n++) {
        Matter.World.add(
          world,
          bumper(bumper_x_odd + n * bumper_x_step, bumper_y)
        );
      }

      bumper_y += bumper_y_step;
    }
    //*/

    //*
    Matter.World.add(world, [
      finishLine(
        world.bounds.max.x / 2,
        world.bounds.max.y - 20,
        world.bounds.max.x - world.bounds.min.x - 40,
        1
      )
    ]);
    //*/
  }

  function createEvents() {
    // events for when the pinball hits stuff
    Matter.Events.on(engine, "collisionStart", function(event) {
      var pairs = event.pairs;
      pairs.forEach(function(pair) {
        //alert(pair.bodyA.label + ' --> ' + pair.bodyB.label);
        if (pair.bodyA.label === "pinball") {
          switch (pair.bodyB.label) {
            case "finishLine":
              //alert('score: ' + currentScore);
              //resetScene();
              break;
            case "bumper":
              pingBumper(pair.bodyB);
              break;
          }
        }
      });
    });

    // regulate pinball
    Matter.Events.on(engine, "beforeUpdate", function(event) {
      // bumpers can quickly multiply velocity, so keep that in check
      Matter.Body.setVelocity(pinball, {
        x: Math.max(Math.min(pinball.velocity.x, MAX_VELOCITY), -MAX_VELOCITY),
        y: Math.max(Math.min(pinball.velocity.y, MAX_VELOCITY), -MAX_VELOCITY)
      });

      if (!pinballDropped) {
        craneMovementOffset += 0.03;

        if (craneMovementOffset < 0) {
          return;
        }

        var px = 250 + 175 * Math.sin(craneMovementOffset);

        // body is static so must manually update velocity for friction to work
        Matter.Body.setVelocity(crane, { x: px - crane.position.x, y: 0 });
        Matter.Body.setPosition(crane, { x: px, y: crane.position.y });

        Matter.Body.setPosition(pinball, {
          x: crane.position.x,
          y: crane.bounds.max.y + PINBALL_RADIUS
        });
      } else {
        craneMovementOffset = 0;
        Matter.Body.setVelocity(crane, { x: 0, y: 0 });
      }
    });

    // use the engine tick event to control our view
    Matter.Events.on(engine, "beforeTick", function() {
      const bounds_center_y = (render.bounds.max.y - render.bounds.min.y) / 2;

      var new_pos_min_y = pinball.position.y - bounds_center_y;
      var new_pos_max_y = pinball.position.y + bounds_center_y;

      if (new_pos_min_y >= 0 && new_pos_max_y <= world.bounds.max.y) {
        render.bounds.min.y = new_pos_min_y;
        render.bounds.max.y = new_pos_max_y;
      }
    });

    // click/tap button events
    $(".button-start")
      //.on('mousedown touchstart', function(e) {
      //})
      .on("mouseup touchend", function(e) {
        dropPinball();
      });
    $(".button-reset")
      //.on('mousedown touchstart', function(e) {
      //})
      .on("mouseup touchend", function(e) {
        resetScene();
      });
  }

  function pingBumper(bumper) {
    if (!bumperHitArray.includes(bumper)) {
      bumperHitArray.push(bumper);
      bumper.render.fillStyle = COLOR.BUMPER_LIT;
      updateScore(currentScore + 10);
    }

    // flash color
    //bumper.render.fillStyle = COLOR.BUMPER_LIT;
    //setTimeout(function() {
    //	bumper.render.fillStyle = COLOR.BUMPER;
    //}, 100);
  }

  function dropPinball() {
    pinballDropped = true;
    Matter.Body.setStatic(pinball, false);
    $(".button-start").prop("disabled", true);
    $(".button-reset").prop("disabled", false);
  }

  function resetScene() {
    pinballDropped = false;
    Matter.Body.setStatic(pinball, true);
    resetBumper();
    updateScore(0);
    
    render.bounds.min.y = 0;
    render.bounds.max.y = RENDER_SIZE.y;
    
    $(".button-start").prop("disabled", false);
    $(".button-reset").prop("disabled", true);
  }

  function resetBumper() {
    bumperHitArray.forEach(function(bumper) {
      bumper.render.fillStyle = COLOR.BUMPER;
    });
    bumperHitArray = Array();
  }

  function updateScore(newCurrentScore) {
    currentScore = newCurrentScore;
    $currentScore.text(currentScore);

    highScore = Math.max(currentScore, highScore);
    $highScore.text(highScore);
  }

  // matter.js has a built in random range function, but it is deterministic
  function rand(min, max) {
    return Math.random() * (max - min) + min;
  }

  // outer edges of pinball table
  function boundary(x, y, width, height) {
    return Matter.Bodies.rectangle(x, y, width, height, {
      isStatic: true,
      render: {
        fillStyle: COLOR.OUTER
      }
    });
  }

  /*
  // bodies created from SVG paths
  function path(x, y, path) {
    let vertices = Matter.Vertices.fromPath(path);
    return Matter.Bodies.fromVertices(x, y, vertices, {
      isStatic: true,
      render: {
        fillStyle: COLOR.OUTER,

        // add stroke and line width to fill in slight gaps between fragments
        strokeStyle: COLOR.OUTER,
        lineWidth: 1
      }
    });
  }
  */

  // round bodies that repel pinball
  function bumper(x, y) {
    var bumper = Matter.Bodies.circle(x, y, BUMPER_RADIUS, {
      label: "bumper",
      isStatic: true,
      render: {
        fillStyle: COLOR.BUMPER
      }
    });

    // for some reason, restitution is reset unless it's set after body creation
    bumper.restitution = BUMPER_BOUNCE;

    return bumper;
  }

  // contact with these bodies causes pinball to be relaunched
  function finishLine(x, y, width, height) {
    return Matter.Bodies.rectangle(x, y, width, height, {
      label: "finishLine",
      isStatic: true,
      render: {
        fillStyle: "#fff"
      }
    });
  }

  window.addEventListener("load", load, false);
})();

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js
  2. https://cdn.rawgit.com/schteppe/poly-decomp.js/1ef946f1/build/decomp.min.js
  3. https://cdn.rawgit.com/liabru/matter-js/0895d81f/build/matter.min.js
  4. https://cdn.rawgit.com/liabru/matter-attractors/c470ed42/build/matter-attractors.min.js