<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;
			}
		}
	}
}




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