LOADING...

Preview

Pen ID
Unlock Campus Themeforest adv

 

Code
Login
  • 1:00
Click to zoom, click and drag to move.
Click to draw pixel, click and drag to move.
Loading...
+
-
CSS
html, body {
  width: 100%;
  height: 100%;
  overflow: hidden;
  margin: 0;
  padding: 0;
  font-family: "Lato", "Lucida Grande","Lucida Sans Unicode", Tahoma, Sans-Serif;
  -webkit-user-select: none;
     -moz-user-select: none;
      -ms-user-select: none;
          user-select: none;
}

body {
  background-color: lightgray;
}

#canvas {
  position: absolute;
  top: 0;
  bottom: 60px;
  left: 0;
  right: 0;
}

.controls {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: #343436;
  padding: 0;
  display: -webkit-box;
  display: -ms-flexbox;
  display: flex;
  -webkit-box-orient: horizontal;
  -webkit-box-direction: normal;
      -ms-flex-direction: row;
          flex-direction: row;
  -webkit-box-pack: justify;
      -ms-flex-pack: justify;
          justify-content: space-between;
  -webkit-box-align: center;
      -ms-flex-align: center;
          align-items: center;
  overflow: hidden;
}
.controls .face-space {
  width: 60px;
}

#auth {
  cursor: pointer;
  display: -webkit-box;
  display: -ms-flexbox;
  display: flex;
  -webkit-box-orient: horizontal;
  -webkit-box-direction: normal;
      -ms-flex-direction: row;
          flex-direction: row;
  -webkit-box-pack: justify;
      -ms-flex-pack: justify;
          justify-content: space-between;
  -webkit-box-align: center;
      -ms-flex-align: center;
          align-items: center;
  color: white;
  padding: 0 20px;
  height: 100%;
  min-height: 60px;
  background-color: #66666E;
}
#auth:hover {
  background-color: #000;
}
#auth svg {
  height: 100%;
}
#auth span {
  margin-left: 10px;
}

#colors {
  margin: 0;
  padding: 10px;
  -webkit-box-flex: 1;
      -ms-flex: 1;
          flex: 1;
  text-align: center;
  -webkit-transition: -webkit-transform 0.5s ease-in-out;
  transition: -webkit-transform 0.5s ease-in-out;
  transition: transform 0.5s ease-in-out;
  transition: transform 0.5s ease-in-out, -webkit-transform 0.5s ease-in-out;
  position: relative;
}
#colors:before {
  color: white;
  display: block;
  position: absolute;
  bottom: 100%;
  width: 100%;
  padding: 10px;
  text-align: center;
  content: "You're free to look around, but to help prevent spam you need to login before you can draw, sorry about that.";
}
#colors li {
  width: 25px;
  height: 25px;
  display: inline-block;
  list-style: none;
  margin: 0;
}
#colors li.active {
  outline: 3px solid white;
}
#colors li:not(:last-child) {
  margin-right: 5px;
}
#colors li#c-ffffff {
  background-color: #ffffff;
  outline-color: #bbb;
}
#colors li#c-e4e4e4 {
  background-color: #e4e4e4;
}
#colors li#c-888888 {
  background-color: #888888;
}
#colors li#c-222222 {
  background-color: #222222;
}
#colors li#c-ffa7d1 {
  background-color: #ffa7d1;
}
#colors li#c-e50000 {
  background-color: #e50000;
}
#colors li#c-e59500 {
  background-color: #e59500;
}
#colors li#c-a06a42 {
  background-color: #a06a42;
}
#colors li#c-e5d900 {
  background-color: #e5d900;
}
#colors li#c-94e044 {
  background-color: #94e044;
}
#colors li#c-02be01 {
  background-color: #02be01;
}
#colors li#c-00d3dd {
  background-color: #00d3dd;
}
#colors li#c-0083c7 {
  background-color: #0083c7;
}
#colors li#c-0000ea {
  background-color: #0000ea;
}
#colors li#c-cf6ee4 {
  background-color: #cf6ee4;
}
#colors li#c-820080 {
  background-color: #820080;
}

.cooldown {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  background-color: #343436;
  -webkit-box-orient: horizontal;
  -webkit-box-direction: normal;
      -ms-flex-direction: row;
          flex-direction: row;
  -webkit-box-pack: center;
      -ms-flex-pack: center;
          justify-content: center;
  -webkit-box-align: center;
      -ms-flex-align: center;
          align-items: center;
  color: white;
  text-align: center;
  display: none;
}

.info {
  position: absolute;
  top: 10px;
  left: 10px;
  background-color: rgba(0, 0, 0, 0.7);
  padding: 10px;
  border-radius: 5px;
  color: white;
  font-size: 12px;
}
.info span {
  color: #ff5555;
}
.info.drawing {
  display: none;
}
.info.loading {
  display: none;
}
.info.general {
  display: block;
}

.loading .info.drawing {
  display: none !important;
}
.loading .info.loading {
  display: block !important;
}
.loading .info.general {
  display: none !important;
}

.zoom-controls {
  position: absolute;
  top: 10px;
  right: 10px;
}
.zoom-controls > div {
  background-color: rgba(0, 0, 0, 0.7);
  padding: 10px;
  border-radius: 5px;
  color: white;
  font-size: 12px;
}
.zoom-controls > div:hover {
  cursor: pointer;
  background-color: black;
}
.zoom-controls > div:not(:first-child) {
  margin-top: 5px;
}

.selectedColor.zoomed .info.drawing {
  display: block;
}
.selectedColor.zoomed .info.general {
  display: none;
}

.dragging #canvas {
  cursor: move;
}

.logged-out #auth {
  -webkit-animation: 1s linear pulse infinite;
          animation: 1s linear pulse infinite;
}
.logged-out #colors {
  -webkit-transform: translateY(100%);
          transform: translateY(100%);
}

.cooling .cooldown {
  display: -webkit-box;
  display: -ms-flexbox;
  display: flex;
}

@-webkit-keyframes pulse {
  0% {
    background: #66666E;
  }
  50% {
    background: #DB5461;
  }
  100% {
    background: #66666E;
  }
}

@keyframes pulse {
  0% {
    background: #66666E;
  }
  50% {
    background: #DB5461;
  }
  100% {
    background: #66666E;
  }
}
JS
// First off, lets clear that blasted console
console.clear();

// TypeScript interfaces

interface Pixel 
{
	uid: string;
	timestamp: number;
	color: string;
}

interface Position
{
	x: number;
	y: number;
}

// Get DOM elements

let body:HTMLElement = document.body;
let authButton:HTMLElement = document.getElementById('auth');
let authButtonText:HTMLElement = document.getElementById('authButtonText');
let canvasContainer:HTMLElement = document.getElementById('canvas');
let coolDownText:HTMLElement = document.getElementById('cooldown-text');
let zoomInButton:HTMLElement = document.getElementById('zoom-in');
let zoomOutButton:HTMLElement = document.getElementById('zoom-out');
let colorOptions:HTMLElement[] = [];

// set loading state

body.classList.add('loading');

// Define consts

const colors = [ 'ffffff',
				 'e4e4e4',
				 '888888',
				 '222222',
				 'ffa7d1',
				 'e50000',
				 'e59500',
				 'a06a42',
				 'e5d900',
				 '94e044',
				 '02be01',
				 '00d3dd',
				 '0083c7',
				 '0000ea',
				 'cf6ee4',
				 '820080'
			   ];

const gridSize = [1000, 1000];
const squareSize = [3, 3];
const coolDownTime = 10000;
const zoomLevel = 6;
const clearColorSelectionOnCoolDown = false;
	  
// Define variables

let uid:string;
let app:any;
let graphics:any;
let gridLines:any;
let container:any;
let dragging:boolean = false;
let mouseDown:boolean = false;
let start:Position;
let graphicsStart:Position;
let selectedColor:string;
let zoomed:boolean = false;
let coolCount:number = 0;
let coolInterval:any;
let scale:number = 1;
let currentlyWriting:string;
let ready:boolean = false;

// I'm adding 5 seconds before I begin downloading
// all the pixels, but only if the pen is running
// as a thumbnail preview. 
// That way I'm hopefully not using up valuable 
// bandwidth on my Firebase account.
let initWait = location.pathname.match(/fullcpgrid/i) ? 5000 : 0;

// Setup Firebase

var config = {
    apiKey: "AIzaSyA4q8u7hRWWGFq_fvTzMxpVypy7W4cTfTk",
    authDomain: "codepen-2.firebaseapp.com",
    databaseURL: "https://codepen-2.firebaseio.com",
    projectId: "codepen-2",
    storageBucket: "codepen-2.appspot.com",
    messagingSenderId: "270463661110"
};

firebase.initializeApp(config);

// Check if user is logged in

firebase.auth().onAuthStateChanged(function(user) 
{
  	if (user) 
  	{
		// user is logged in
    	uid = user.uid;
		
		// set logout button
		authButtonText.innerHTML = 'Logout';	
		
		body.classList.add('logged-in')
		body.classList.remove('logged-out');
  	} 
	else 
	{
    	// user is not logged in
		uid = null;
		
		// set login button
		authButtonText.innerHTML = 'Login with Twitter';
		
		body.classList.remove('logged-in')
		body.classList.add('logged-out')
  	}
	
	authButton.addEventListener("click", toggleLogin);
});

// run stage setup
setupStage();
setupColorOptions();

// start listening for new pixels
setTimeout(startListeners, initWait);

// Auth functions

function login()
{
	// Use Twitter for login
	var provider = new firebase.auth.TwitterAuthProvider();
	// Open Twitter auth window
	firebase.auth().signInWithPopup(provider).catch(error => console.log('error logging in', error));
}

function logout()
{
	firebase.auth().signOut().catch(error => console.error('error logging out', error));	
}

function toggleLogin()
{
	if(uid) logout();
	else login();
}

// Write pixel to database functions

function writePixel(x:number, y: number, color:string)
{
	if(uid)
	{
		console.log('writing pixel...')
		
		// First we need to get a valid timestamp.
		// To stop spamming the rules on the database 
		// prevent creating a new timestamp within a 
		// set period. 
		
		getTimestamp().then( timestamp => 
		{	
			// we've successfully set a new timestamp.
			// This means the cooldown period is
			// over and the user is free to save
			// their new pixel to the database
			
			var data:Pixel = {
			  uid: uid,
			  timestamp: timestamp,
			  color: color
			};
			
			currentlyWriting = x + 'x' + y;

			// We set the new pixel data with the key 'XxY'
			// for example "56x93"
			
			var ref = firebase.database().ref('pixel/' + currentlyWriting).set(data)
				.then( () => 
				{
					// Pixel successfully saved, we'll wait for
					// the pixel listeners to pick up the new
					// pixel before drawing it on the canvas.
					currentlyWriting = null;
					startCoolDown();
					
					console.log('success!') 
				})
				.catch( error => 
				{
					// Error here is probably due to the internet
					// connection going down between generating
					// the timestamp and saving the pixel.
					// The database has a rule set to check the 
					// timestamp generated and the timestamp 
					// sent with the pixel.
					
					// It could also be due to usage limits on
					// the free tier of Firebase.
					
					console.error('could not write pixel');
				})
		})
		.catch( error => 
		{
			// Failed to create a new timestamp.
			// Probably because the user hasn't 
			// waited for their cool down period
			// to finish. 
			
			console.error('you need to wait for cool down period to finish')
		})
	}
}

function startCoolDown()
{
	// deselect the color
	if(clearColorSelectionOnCoolDown) selectColor(null);
	
	// add the .cooling class to the body
	// So the countdown clock appears
	body.classList.add('cooling');
	
	// start a timeout for the cooldown time
	// in milliseconds, the milliseconds are
	// also set in the database rules so removing
	// this code doesn't allow the user to skip
	// cooldown
	setTimeout(() => endCoolDown(), coolDownTime);
	
	// coolCount (😎) is used to write the countdown
	// clock.
	coolCount = coolDownTime;
	
	// update countdown clock first
	updateCoolCounter();
	
	// start an interval to update the countdown
	// clock every second
	clearInterval(coolInterval);
	coolInterval = setInterval(updateCoolCounter, 1000);
}

function updateCoolCounter()
{
	// Work out minutes and seconds left from 
	// the remaining milliseconds in coolCount
	let mins = String(Math.floor((coolCount/(1000*60))%60));
	let secs = String((coolCount/1000)%60);
	
	// update the cooldown counter in the DOM
	coolDownText.innerHTML = mins + ':' + (secs.length < 2 ? '0' : '') + secs;
	
	// remove 1 secound (1000 milliseconds) 
	// ready for the next update.
	coolCount -= 1000;
}

function endCoolDown()
{
	// set coolCount to 0, just in case it went
	// over, intervals aren't perfect.
	coolCount = 0;
	
	// stop the update interval
	clearInterval(coolInterval);
	
	// remove the .cooling class from the body
	// so that the countdown clock is hidden.
	body.classList.remove('cooling')
}

function getTimestamp():Promise
{
	let promise = new Promise((resolve, reject) => {
		
		// Update user's "last_write" with
		// new timestamp
		
		var ref = firebase.database().ref('last_write/' + uid);
		ref.set(firebase.database.ServerValue.TIMESTAMP)
			.then( () => 
			{
				// Timestamp is saved, but because
				// the database generates this we
				// don't know what it is, so we have
				// to ask for it.
			
				ref.once('value')
					.then(timestamp => 
					{
						// We have the new timestamp.
						resolve(timestamp.val());
					})
					.catch(reject)
			})
			.catch(reject)
	})
	
	return promise;
}

// Draw pixel functions

function startListeners()
{
	console.log('Starting Firebase listeners');
	
	// get a reference to the pixel table 
	// in the database
	let placeRef = firebase.database().ref("pixel");
	
	
	// get once update on all the values 
	// in the grid so we can draw everything
	// on first load.
	// placeRef.once('value')
	// 	.then(snapshot => 
	// 	{
	// 		// draw all the pixels in the grid
	// 		var grid = snapshot.val();
	// 		for(let i in grid)
	// 		{
	// 			renderPixel(i, grid[i]);
	// 		}
		
			// start listening for changes to pixels 
			placeRef.on('child_changed', onChange);
		
			// also start listening for new pixels,
			// grid position that have never had a 
			// pixel drawn on them are new.
			placeRef.on('child_added', onChange);
		// })
		// .catch(error => {
		// 	console.log(error);
		// })
	
	ready = true;
}

function onChange(change)
{
	body.classList.remove('loading');
	
	// render the new pixel
	// key will be the grid position,
	// for example "34x764"
	// val will be a pixel object defined
	// by the Pixel interface at the top.
	renderPixel(change.key, change.val());
}

function setupStage()
{
	// Setting up canvas with Pixi.js
	app = new PIXI.Application(window.innerWidth, window.innerHeight-60, { antialias: false, backgroundColor : 0xeeeeee });
	canvasContainer.appendChild(app.view);
	
	// create a container for the grid
	// container will be used for zooming
	container = new PIXI.Container();
	
	// and container to the stage
	app.stage.addChild(container);

	// graphics is the cavas we draw the 
	// pixels on, well also move this around
	// when the user drags around
	graphics = new PIXI.Graphics();
	graphics.beginFill(0xffffff, 1);
	graphics.drawRect(0, 0, gridSize[0] * squareSize[0], gridSize[1] * squareSize[1]);
	graphics.interactive = true;
	
	// setup input listeners, we use
	// pointerdown, pointermove, etc 
	// rather than mousedown, mousemove,
	// etc, because it triggers on both
	// mouse and touch
	graphics.on('pointerdown', onDown);
	graphics.on('pointermove', onMove);
	graphics.on('pointerup', onUp);
	graphics.on('pointerupoutside', onUp);
	
	// move graphics so that it's center
	// is at x0 y0
	graphics.position.x = -graphics.width/2;
	graphics.position.y = -graphics.height/2;
	
	
			
	// place graphics into the container
	container.addChild(graphics);
	
	gridLines = new PIXI.Graphics();
	gridLines.lineStyle(0.5, 0x888888, 1);
	gridLines.alpha = 0;
	
	gridLines.position.x = graphics.position.x;
	gridLines.position.y = graphics.position.y;
	
	for(let i = 0; i <= gridSize[0]; i++)
	{
		drawLine(0, i * squareSize[0], gridSize[0] * squareSize[0], i * squareSize[0])	
	}
	for(let j = 0; j <= gridSize[1]; j++)
	{
		drawLine(j * squareSize[1], 0, j * squareSize[1], gridSize[1] * squareSize[1])
	}
	
	container.addChild(gridLines);
	
	// start page resize listener, so 
	// we can keep the canvas the correct
	// size
	window.onresize = onResize;
	
	// make canvas fill the screen.
	onResize();
	
	// add zoom button controls
	zoomInButton.addEventListener("click", () => { toggleZoom({x: window.innerWidth / 2, y: window.innerHeight / 2}, true) });
	zoomOutButton.addEventListener("click", () => { toggleZoom({x: window.innerWidth / 2, y: window.innerHeight / 2}, false) });
}

function drawLine(x, y, x2, y2)
{
	
	
		gridLines.moveTo(x, y);
		gridLines.lineTo(x2, y2);
	
}

function setupColorOptions()
{
	// link up the color options with
	// a click function.
	for(let i in colors)
	{
		// each color button has an id="c-" then
		// the color value, for example "c-ffffff"
		// is the white color buttton.
		let element = document.getElementById('c-' + colors[i]);
		
		// on click send the color to the selectColor
		// function
		element.addEventListener("click", (e) => {selectColor(colors[i]) });
		
		// add the DOM element to an array so
		// we can use it again later
		colorOptions.push(element);
	}
}

function selectColor(color: string)
{
	if(selectedColor !== color)
	{
		// if the new color does not match
		// the current selected color then
		// change it the new one
		selectedColor = color;
		
		// add the .selectedColor class to
		// the body tag. We use this to update
		// the info box instructions.
		body.classList.add('selectedColor');
	}
	else
	{
		// if the new color matches the
		// currently selected on the user
		// is toggling the color off.
		selectedColor = null;
		
		// remove the .selectedColor class
		// from the body.
		body.classList.remove('selectedColor');
	}
	
	for(let i in colors)
	{
		// loop through all the colors in,
		// if the color equals the selected
		// color add the .active class to the
		// button element
		if(colors[i] == selectedColor) colorOptions[i].classList.add('active');
		// otherwise remove the .active class
		else colorOptions[i].classList.remove('active');
	}
}

function onResize(e)
{
	// resize the canvas to fill the screen
	app.renderer.resize(window.innerWidth, window.innerHeight);
	
	// center the container to the new
	// window size.
	container.position.x = window.innerWidth / 2;
	container.position.y = window.innerHeight / 2;
}

function onDown(e)
{
	// Pixi.js adds all its mouse listeners
	// to the window, regardless of which
	// element they are assigned to inside the
	// canvas. So to avoid zooming in when 
	// selecting a color we first check if the
	// click is not withing the bottom 60px where
	// the color options are
	if(e.data.global.y < window.innerHeight - 60 && ready) 
	{
		// We save the mouse down position
		start = {x: e.data.global.x, y: e.data.global.y};
		
		// And set a flag to say the mouse
		// is now down
		mouseDown = true;
	}
}

function onMove(e)
{
	// check if mouse is down (in other words
	// check if the user has clicked or touched 
	// down and not yet lifted off)
	if(mouseDown)
	{	
		// if not yet detected a drag then...
		if(!dragging)
		{
			// we get the mouses current position
			let pos = e.data.global;
			
			// and check if that new position has
			// move more than 5 pixels in any direction
			// from the first mouse down position
			if(Math.abs(start.x - pos.x) > 5 || Math.abs(start.y - pos.y) > 5)
			{
				// if it has we can assume the user
				// is trying to draw the view around
				// and not clicking. We store the 
				// graphics current position do we 
				// can offset its postion with the
				// mouse position later.
				graphicsStart = {x: graphics.position.x, y: graphics.position.y};
				
				// set the dragging flag
				dragging = true;
				
				// add the .dragging class to the 
				// DOM so we can switch to the 
				// move cursor
				body.classList.add('dragging');
			}
		}

		if(dragging)
		{
			// update the graphics position based
			// on the mouse position, offset with the
			// start and graphics orginal positions
			graphics.position.x = ((e.data.global.x - start.x)/scale) + graphicsStart.x;
			graphics.position.y = ((e.data.global.y - start.y)/scale) + graphicsStart.y;
			
			gridLines.position.x = ((e.data.global.x - start.x)/scale) + graphicsStart.x;
			gridLines.position.y = ((e.data.global.y - start.y)/scale) + graphicsStart.y;
		}
	}
}

function onUp(e)
{
	// clear the .dragging class from DOM
	body.classList.remove('dragging');
	
	// ignore the mouse up if the mouse down
	// was out of bounds (e.g in the bottom
	// 60px)
	if(mouseDown && ready)
	{
		// clear mouseDown flag
		mouseDown = false;
		
		// if the dragging flag was never set
		// during all the mouse moves then this 
		// is a click
		if(!dragging)
		{
			// if a color has been selected and
			// the view is zoomed in then this
			// click is to draw a new pixel
			if(selectedColor && zoomed)
			{
				// get the latest mouse position 
				let position = e.data.getLocalPosition(graphics);
				
				// round the x and y down
				let x = Math.floor(position.x / squareSize[0]);
				let y = Math.floor(position.y / squareSize[1]);
				
				writePixel(x, y, selectedColor);
			}
			else
			{
				// either a color has not been selected
				// or it has but the user is zoomed out,
				// either way this click is to toggle the
				// zoom level
				toggleZoom(e.data.global)
			}
		}
		dragging = false;
	}
}

function renderPixel(pos:string, pixel: Pixel)
{
	// split the pos string at the 'x'
	// so '100x200' would become an
	// array ['100', '200']
	let split = pos.split('x');
	
	// assign the values to x and y 
	// vars using + to convert the 
	// string to a number
	let x = +split[0];
	let y = +split[1];
	
	// grab the color from the pixel
	// object
	let color = pixel.color;
	
	// draw the square on the graphics canvas
	graphics.beginFill(parseInt('0x' + color), 1);
	graphics.drawRect(x * squareSize[0], y * squareSize[1], squareSize[0], squareSize[1]);
}

function toggleZoom(offset:Position, forceZoom?:boolean)
{
	console.log(forceZoom)
	// toggle the zoomed varable
	zoomed = forceZoom !== undefined ? forceZoom : !zoomed;
	
	// scale will equal 4 if zoomed (so 4x bigger), 
	// other otherwise the scale will be 1
	scale =  zoomed ? zoomLevel : 1;
	
	// add or remove the .zoomed class to the 
	// body tag. This is so we can change the 
	// info box instructions
	if(zoomed) body.classList.add('zoomed');
	else body.classList.remove('zoomed');	
	
	let opacity = zoomed ? 1 : 0;
	
	// Use GSAP to animate between scales.
	// We are scaling the container and not
	// the graphics. 
	TweenMax.to(container.scale, 0.5, {x: scale, y: scale, ease:Power3.easeInOut});
	let x = offset.x - (window.innerWidth / 2);
	let y = offset.y - (window.innerHeight / 2);
	let newX = zoomed ? graphics.position.x - x : graphics.position.x + x;
	let newY = zoomed ? graphics.position.y - y : graphics.position.y + y;
	TweenMax.to(graphics.position, 0.5, {x: newX, y: newY, ease:Power3.easeInOut});
	TweenMax.to(gridLines.position, 0.5, {x: newX, y: newY, ease:Power3.easeInOut});
	TweenMax.to(gridLines, 0.5, {alpha: opacity, ease:Power3.easeInOut});
}

/*

Firebase database rules are set to...

{
  "rules": {
    "last_write": {
      "$user": {
        ".read": "auth.uid === $user",
        ".write": "newData.exists() && auth.uid === $user",
        ".validate": "newData.isNumber() && newData.val() === now && (!data.exists() || newData.val() > data.val()+10000)"
      }
    },
    "pixel": {
      ".read": "true",
      "$square": {
        	".write": "auth !== null",
        	".validate": "newData.hasChildren(['uid', 'color', 'timestamp']) && $square.matches(/^([0-9]|[1-9][0-9]|[1-9][0-9][0-9])x([0-9]|[1-9][0-9]|[1-9][0-9][0-9])$/)",
      		"uid": {
            ".validate": "newData.val() === auth.uid"
          },
          "timestamp": {
            ".validate": "newData.val() >= now - 500 && newData.val() === data.parent().parent().parent().child('last_write/'+auth.uid).val()"
          },
          "color": {
            ".validate": "newData.isString() && newData.val().length === 6 && newData.val().matches(/^(ffffff|e4e4e4|888888|222222|ffa7d1|e50000|e59500|a06a42|e5d900|94e044|02be01|00d3dd|0083c7|0000ea|cf6ee4|820080)$/)"
          }
      }
    }
  }
}

*/



// the rules to prevent adding pixels during cooldown
// were written with help from http://stackoverflow.com/a/24841859
Host Instantly Drag and Drop Website Builder

 

Description

A replica of Reddit's Place, a collaborative drawing app lasting 72hrs on April fools day 2017. https://www.reddit.com/r/place/.
Term
Mon, 11/27/2017 - 21:52

Related Codes

Pen ID
Pen ID
Pen ID