<meta name="viewport" content="width=device-width,user-scalable=no">

<div id="container">
  <div id="game"></div>
  <div id="score">0</div>
  <div id="instructions">Click (or press the spacebar) to place the block</div>
  <div class="game-over">
    <h2>Game Over</h2>
    <p>You did great, you're the best.</p>
    <p>Click or spacebar to start again</p>
  </div>
  <div class="game-ready">
    <div id="start-button">Start</div>
    <div></div>
  </div>
</div>
@import url('https://fonts.googleapis.com/css?family=Comfortaa');

$color-dark: #333344;

html, body
{
  margin: 0;
  overflow: hidden;
  height: 100%;
  width: 100%;
  position: relative;
  font-family: 'Comfortaa', cursive;
}

#container
{
  width: 100%;
  height: 100%;
  
  #score
  {
    position: absolute;
    top: 20px;
    width: 100%;
    text-align: center;
    font-size: 10vh;
    transition: transform 0.5s ease;
    color: $color-dark;
    transform: translatey(-200px) scale(1);
  }

  #game
  {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
  }
  
  .game-over
  {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 85%;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    
    *
    {
      transition: opacity 0.5s ease, transform 0.5s ease;
      opacity: 0;
      transform: translatey(-50px);
      color: $color-dark;
    }
    
    h2
    {
      margin: 0;
      padding: 0;
      font-size: 40px;
    }
  }
  
  .game-ready
  {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: space-around;
    
    #start-button
    {
      transition: opacity 0.5s ease, transform 0.5s ease;
      opacity: 0;
      transform: translatey(-50px);
      
      border: 3px solid $color-dark;
      padding: 10px 20px;
      background-color: transparent;
      color: $color-dark;
      font-size: 30px;
    }
  }
  
  #instructions
  {
    position: absolute;
    width: 100%;
    top: 16vh;
    left: 0;
    text-align: center;
    transition: opacity 0.5s ease, transform 0.5s ease;
    
    opacity: 0;
    
    &.hide
    {
      opacity: 0 !important;
    }
  }
  
  &.playing, &.resetting
  {
    #score
    {
      transform: translatey(0px) scale(1);
    }
  }
  
  &.playing
  {
    #instructions
    {
      opacity: 1;
    }
  }
  
  &.ready
  {
    
    
    .game-ready
    {
      #start-button
      {
        opacity: 1;
        transform: translatey(0);
      }
    }
  }
  
  &.ended
  {
    #score
    {
      transform: translatey(6vh) scale(1.5);
    }
    
    .game-over
    {
      *
      {
        opacity: 1;
        transform: translatey(0);
      }
      
      p
      {
        transition-delay: 0.3s;
      }
    }
  }
}




View Compiled
console.clear();

interface BlockReturn
{
  placed?:any;
  chopped?:any;
  plane: 'x' | 'y' | 'z';
  direction: number;
  bonus?: boolean;
}

class Stage
{
  private container: any;
  private camera: any;
  private scene: any;
  private renderer: any;
  private light: any;
  private softLight: any;
  private group: any;
  
  constructor()
  {
    // container
    
    this.container = document.getElementById('game');
    
    // renderer
    
    this.renderer = new THREE.WebGLRenderer({
      antialias: true,
      alpha: false
    });
    
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    this.renderer.setClearColor('#D0CBC7', 1);
    this.container.appendChild( this.renderer.domElement );
    
    // scene

    this.scene = new THREE.Scene();

    // camera

    let aspect = window.innerWidth / window.innerHeight;
    let d = 20;
    this.camera = new THREE.OrthographicCamera( - d * aspect, d * aspect, d, - d, -100, 1000);
    this.camera.position.x = 2;
    this.camera.position.y = 2; 
    this.camera.position.z = 2; 
    this.camera.lookAt(new THREE.Vector3(0, 0, 0));
    
    //light

    this.light = new THREE.DirectionalLight(0xffffff, 0.5);
    this.light.position.set(0, 499, 0);
    this.scene.add(this.light);

    this.softLight = new THREE.AmbientLight( 0xffffff, 0.4 );
    this.scene.add(this.softLight)
    
    window.addEventListener('resize', () => this.onResize());
    this.onResize();
  }
  
  setCamera(y:number, speed:number = 0.3)
  {
    TweenLite.to(this.camera.position, speed, {y: y + 4, ease: Power1.easeInOut});
    TweenLite.to(this.camera.lookAt, speed, {y: y, ease: Power1.easeInOut});
  }
  
  onResize()
  {
    let viewSize = 30;
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    this.camera.left = window.innerWidth / - viewSize;
    this.camera.right = window.innerWidth / viewSize;
    this.camera.top = window.innerHeight / viewSize;
    this.camera.bottom = window.innerHeight / - viewSize;
    this.camera.updateProjectionMatrix();
  }
  
  render = function()
  {
    this.renderer.render(this.scene, this.camera);
  }

  add = function(elem)
  {
    this.scene.add(elem);
  }

  remove = function(elem)
  {
    this.scene.remove(elem);
  }
}

class Block
{
  const STATES = {ACTIVE: 'active', STOPPED: 'stopped', MISSED: 'missed'};
  const MOVE_AMOUNT = 12;

  dimension = { width: 0, height: 0, depth: 0}
  position = {x: 0, y: 0, z: 0};
  
  mesh:any;
  state:string;
  index:number;
  speed:number;
  direction:number;
  colorOffset:number;
  color:number;
  material:any;

  workingPlane:string;
  workingDimension:string;

  targetBlock:Block;
  
  constructor(block:Block)
  {
    // set size and position
    
    this.targetBlock = block;
    
    this.index = (this.targetBlock ? this.targetBlock.index : 0) + 1;
    this.workingPlane = this.index % 2 ? 'x' : 'z';
    this.workingDimension = this.index % 2 ? 'width' : 'depth';
    
    // set the dimensions from the target block, or defaults.
    
    this.dimension.width = this.targetBlock ? this.targetBlock.dimension.width : 10;
    this.dimension.height = this.targetBlock ? this.targetBlock.dimension.height : 2;
    this.dimension.depth = this.targetBlock ? this.targetBlock.dimension.depth : 10;
    
    this.position.x = this.targetBlock ? this.targetBlock.position.x : 0;
    this.position.y = this.dimension.height * this.index;
    this.position.z = this.targetBlock ? this.targetBlock.position.z : 0;
    
    this.colorOffset = this.targetBlock ? this.targetBlock.colorOffset : Math.round(Math.random() * 100);
    
    // set color
    if(!this.targetBlock) 
    {
      this.color = 0x333344;
    }
    else
    {
      let offset = this.index + this.colorOffset;
      var r = Math.sin(0.3 * offset) * 55 + 200;
      var g = Math.sin(0.3 * offset + 2) * 55 + 200;
      var b = Math.sin(0.3 * offset + 4) * 55 + 200;
      this.color = new THREE.Color( r / 255, g / 255, b / 255 );
    }
    
    // state
    
    this.state = this.index > 1 ? this.STATES.ACTIVE : this.STATES.STOPPED;
    
    // set direction
    
    this.speed = -0.1 - (this.index * 0.005);
    if(this.speed < -4) this.speed = -4;
    this.direction = this.speed;
    
    // create block
    
    let geometry = new THREE.BoxGeometry( this.dimension.width, this.dimension.height, this.dimension.depth);
    geometry.applyMatrix( new THREE.Matrix4().makeTranslation(this.dimension.width/2, this.dimension.height/2, this.dimension.depth/2) );
    this.material = new THREE.MeshToonMaterial( {color: this.color, shading: THREE.FlatShading} );
    this.mesh = new THREE.Mesh( geometry, this.material );
    this.mesh.position.set(this.position.x, this.position.y + (this.state == this.STATES.ACTIVE ? 0 : 0), this.position.z);
    
    if(this.state == this.STATES.ACTIVE) 
    {
      this.position[this.workingPlane] = Math.random() > 0.5 ? -this.MOVE_AMOUNT : this.MOVE_AMOUNT;
    }
  } 

  reverseDirection()
  {
    this.direction = this.direction > 0 ? this.speed : Math.abs(this.speed);  
  }

  place():BlockReturn
  {
    this.state = this.STATES.STOPPED;
    
    let overlap = this.targetBlock.dimension[this.workingDimension] - Math.abs(this.position[this.workingPlane] - this.targetBlock.position[this.workingPlane]);
    
    let blocksToReturn:BlockReturn = {
      plane: this.workingPlane,
      direction: this.direction
    };
    
    if(this.dimension[this.workingDimension] - overlap < 0.3)
    {
      overlap = this.dimension[this.workingDimension];
      blocksToReturn.bonus = true;
      this.position.x = this.targetBlock.position.x;
      this.position.z = this.targetBlock.position.z;
      this.dimension.width = this.targetBlock.dimension.width;
      this.dimension.depth = this.targetBlock.dimension.depth;
    }
    
    if(overlap > 0)
    {
      let choppedDimensions = { width: this.dimension.width, height: this.dimension.height, depth: this.dimension.depth };
      choppedDimensions[this.workingDimension] -= overlap;
      this.dimension[this.workingDimension] = overlap;
          
      let placedGeometry = new THREE.BoxGeometry( this.dimension.width, this.dimension.height, this.dimension.depth);
      placedGeometry.applyMatrix( new THREE.Matrix4().makeTranslation(this.dimension.width/2, this.dimension.height/2, this.dimension.depth/2) );
      let placedMesh = new THREE.Mesh( placedGeometry, this.material );
      
      let choppedGeometry = new THREE.BoxGeometry( choppedDimensions.width, choppedDimensions.height, choppedDimensions.depth);
      choppedGeometry.applyMatrix( new THREE.Matrix4().makeTranslation(choppedDimensions.width/2, choppedDimensions.height/2, choppedDimensions.depth/2) );
      let choppedMesh = new THREE.Mesh( choppedGeometry, this.material );
      
      let choppedPosition = {
        x: this.position.x,
        y: this.position.y,
        z: this.position.z
      }
      
      if(this.position[this.workingPlane] < this.targetBlock.position[this.workingPlane])
      {
        this.position[this.workingPlane] = this.targetBlock.position[this.workingPlane]
      }
      else
      {
        choppedPosition[this.workingPlane] += overlap;
      }
      
      placedMesh.position.set(this.position.x, this.position.y, this.position.z);
      choppedMesh.position.set(choppedPosition.x, choppedPosition.y, choppedPosition.z);
      
      blocksToReturn.placed = placedMesh;
      if(!blocksToReturn.bonus) blocksToReturn.chopped = choppedMesh;
    }
    else
    {
      this.state = this.STATES.MISSED;
    }
    
    this.dimension[this.workingDimension] = overlap;

    return blocksToReturn;
  }
  
  tick()
  {
    if(this.state == this.STATES.ACTIVE)
    {
      let value = this.position[this.workingPlane];
      if(value > this.MOVE_AMOUNT || value < -this.MOVE_AMOUNT) this.reverseDirection();
      this.position[this.workingPlane] += this.direction; 
      this.mesh.position[this.workingPlane] = this.position[this.workingPlane]; 
    }
  }
}

class Game
{
  const STATES = {
    'LOADING': 'loading',
    'PLAYING': 'playing',
    'READY': 'ready',
    'ENDED': 'ended',
    'RESETTING': 'resetting'
  }
  blocks:Block[] = [];
  state:string = this.STATES.LOADING;
  
  // groups

  newBlocks:any;
  placedBlocks:any;
  choppedBlocks:any;

  // UI elements

  scoreContainer:any;
  mainContainer:any;
  startButton:any;
  instructions:any;
  
  constructor()
  {
    this.stage = new Stage();
    
    this.mainContainer = document.getElementById('container');
    this.scoreContainer = document.getElementById('score');
    this.startButton = document.getElementById('start-button');
    this.instructions = document.getElementById('instructions');
    this.scoreContainer.innerHTML = '0';
    
    this.newBlocks = new THREE.Group();
    this.placedBlocks = new THREE.Group();
    this.choppedBlocks = new THREE.Group();
    
    this.stage.add(this.newBlocks);
    this.stage.add(this.placedBlocks);
    this.stage.add(this.choppedBlocks);
    
    this.addBlock();
    this.tick();
    
    this.updateState(this.STATES.READY);
    
    document.addEventListener('keydown', e =>
    {
      if(e.keyCode == 32) this.onAction()
    });
    
    document.addEventListener('click', e =>
    {
      this.onAction();
    });   
    
    document.addEventListener('touchstart', e =>
    {
      e.preventDefault();
      // this.onAction();
      
      // ☝️ this triggers after click on android so you
      // insta-lose, will figure it out later.
    });
  }

  updateState(newState)
  {
    for(let key in this.STATES) this.mainContainer.classList.remove(this.STATES[key]);
    this.mainContainer.classList.add(newState);
    this.state = newState;
  }

  onAction()
  {
    switch(this.state)
    {
      case this.STATES.READY:
        this.startGame();
        break;
      case this.STATES.PLAYING:
        this.placeBlock();
        break;
      case this.STATES.ENDED:
        this.restartGame();
        break;  
    }
  }
  
  startGame()
  {
    if(this.state != this.STATES.PLAYING)
    {
      this.scoreContainer.innerHTML = '0';
      this.updateState(this.STATES.PLAYING);
      this.addBlock();
    }
  }

  restartGame()
  {
    this.updateState(this.STATES.RESETTING);
    
    let oldBlocks = this.placedBlocks.children;
    let removeSpeed = 0.2;
    let delayAmount = 0.02;
    for(let i = 0; i < oldBlocks.length; i++)
    {
      TweenLite.to(oldBlocks[i].scale, removeSpeed, {x: 0, y: 0, z: 0, delay: (oldBlocks.length - i) * delayAmount, ease: Power1.easeIn, onComplete: () => this.placedBlocks.remove(oldBlocks[i])})
      TweenLite.to(oldBlocks[i].rotation, removeSpeed, {y: 0.5, delay: (oldBlocks.length - i) * delayAmount, ease: Power1.easeIn})
    }
    let cameraMoveSpeed = removeSpeed * 2 + (oldBlocks.length * delayAmount);
    this.stage.setCamera(2, cameraMoveSpeed);
    
    let countdown = {value: this.blocks.length - 1};
    TweenLite.to(countdown, cameraMoveSpeed, {value: 0, onUpdate: () => {this.scoreContainer.innerHTML = String(Math.round(countdown.value))}})
    
    this.blocks = this.blocks.slice(0, 1);
    
    setTimeout(() => {
      this.startGame();
    }, cameraMoveSpeed * 1000)
    
  }
  
  placeBlock()
  {
    let currentBlock = this.blocks[this.blocks.length - 1];
    let newBlocks:BlockReturn = currentBlock.place();
    this.newBlocks.remove(currentBlock.mesh);
    if(newBlocks.placed) this.placedBlocks.add(newBlocks.placed);
    if(newBlocks.chopped)
    {
      this.choppedBlocks.add(newBlocks.chopped);
      let positionParams = {y: '-=30', ease: Power1.easeIn, onComplete: () => this.choppedBlocks.remove(newBlocks.chopped)}
      let rotateRandomness = 10;
      let rotationParams = {
        delay: 0.05,
        x: newBlocks.plane == 'z' ? ((Math.random() * rotateRandomness) - (rotateRandomness/2)) : 0.1,
        z: newBlocks.plane == 'x' ? ((Math.random() * rotateRandomness) - (rotateRandomness/2)) : 0.1,
        y: Math.random() * 0.1,
      };
      if(newBlocks.chopped.position[newBlocks.plane] > newBlocks.placed.position[newBlocks.plane])
      {
        positionParams[newBlocks.plane] = '+=' + (40 * Math.abs(newBlocks.direction));
      }
      else
      {
        positionParams[newBlocks.plane] = '-=' + (40 * Math.abs(newBlocks.direction));
      }
      TweenLite.to(newBlocks.chopped.position, 1, positionParams);
      TweenLite.to(newBlocks.chopped.rotation, 1, rotationParams);
      
    }
    
    this.addBlock();
  }
  
  addBlock()
  {
    let lastBlock = this.blocks[this.blocks.length - 1];
    
    if(lastBlock && lastBlock.state == lastBlock.STATES.MISSED)
    {
      return this.endGame();
    }
    
    this.scoreContainer.innerHTML = String(this.blocks.length - 1);
    
    let newKidOnTheBlock = new Block(lastBlock);
    this.newBlocks.add(newKidOnTheBlock.mesh);
    this.blocks.push(newKidOnTheBlock);

    this.stage.setCamera(this.blocks.length * 2);
    
    if(this.blocks.length >= 5) this.instructions.classList.add('hide');
  }
  
  endGame()
  {
    this.updateState(this.STATES.ENDED);
  }

  tick()
  {
    this.blocks[this.blocks.length - 1].tick();
    this.stage.render();
    requestAnimationFrame(() => {this.tick()});
  }
}

let game = new Game();
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://codepen.io/steveg3003/pen/zBVakw.js
  2. https://cdnjs.cloudflare.com/ajax/libs/three.js/r83/three.min.js
  3. https://cdnjs.cloudflare.com/ajax/libs/gsap/latest/TweenMax.min.js