I recently built an easter egg for the Strapi Docs site. My initial motivation was to create a simple little easter egg that would be fun to watch but that soon spiraled out of control. I ended up building an entire game within the docs site that you can play. I will be going over the steps I took to build the easter egg and the challenges I faced along the way.
If you just want to play you can go to docs.strapi.io and enter the Konami code on your keyboard to play the game. (Up, Up, Down, Down, Left, Right, Left, Right, B, A)
My first iteration was a simple particle rain of all the contributor profiles on GitHub.
GitHub has a public API to get all the contributors for a repository but they limit it to 100 per page meaning we will need to make multiple requests to get all the contributors.
const profiles = []let page = 1while (true) { let res try { res = await fetch( 'https://api.github.com/repos/strapi/strapi/contributors?per_page=100&page=' + page ) } catch (e) { console.log(e) break } let data = await res.json() page++ data.forEach((profile) => { if (profile.avatar_url) profiles.push(profile.avatar_url) })}
This code will loop through all the pages of contributors and add the profile image to the array.
What I soon figured out was that GitHub has a rate limit so I easily hit that limit. To solve this problem I stored the profiles inside local storage like this:
const profiles = []if ( localStorage.getItem('profiles') && localStorage.getItem('profilesLastUpdated') && Date.now() - localStorage.getItem('profilesLastUpdated') < 1000 * 60 * 60 * 24 * 7) { // load profiles from local storage profiles.push(...JSON.parse(localStorage.getItem('profiles'))) console.log('loaded profiles from local storage')} else { // fetch all the profiles from github on all the pages let page = 1 while (true) { let res try { res = await fetch( 'https://api.github.com/repos/strapi/strapi/contributors?per_page=100&page=' + page ) } catch (e) { console.log(e) break } let data = await res.json() if (data.length == 0) { localStorage.setItem('profiles', JSON.stringify(profiles)) localStorage.setItem('profilesLastUpdated', Date.now()) break } page++ data.forEach((profile) => { if (profile.avatar_url) profiles.push(profile.avatar_url) }) }}
This checks if the profiles are already in local storage and if they are it will load them from there. If they are not in local storage it will fetch them from GitHub and then store them in local storage. We also store the last time the profiles were updated so we can update them once a week.
After I have all the profile URLs I can create the particle rain. I used simple css animation in order todo this.
const profileDiv = document.createElement('div')profileDiv.className = 'profileRainContainer'document.body.appendChild(profileDiv)setTimeout(() => { profileDiv.remove()}, 10000)async function createProfile(url) { const profile = document.createElement('div') profile.className = 'profileRain' profile.style.left = Math.random() * window.innerWidth - 50 + 'px' profile.style.backgroundImage = `url(${url})` profileDiv.appendChild(profile) setTimeout(() => { profile.remove() }, 3000)}async function spawnProfiles() { profiles.forEach((url) => { let delay = Math.random() * 5000 setTimeout(createProfile, delay, url) })}spawnProfiles()
.profileRainContainer { position: fixed; top: 0px; width: 100%; height: 100%; z-index: 1000;}.profileRain { position: absolute; top: 0px; width: 50px; height: 50px; z-index: 1000; background-color: white; border-radius: 50%; background-image: url('https://via.placeholder.com/50x50'); background-size: cover; animation: rain 3s linear 1;}img.preload { display: none;}@keyframes rain { from { transform: translateY(-1000px); } to { transform: translateY(100vh); }}
I had a profile container that I would add the profile images to. I then used a simple css animation to make the profile images fall down the screen. I also added a delay to the animation so that the profiles would fall at different times.
I've been using the Konami Code for years to hide easter eggs and I wasn't stopping now.
const konamiCode = [ 'ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'KeyB', 'KeyA',]let pressed = []window.addEventListener('keydown', (e) => { pressed.push(e.code) pressed.splice(-konamiCode.length - 1, pressed.length - konamiCode.length) if (pressed.join('').includes(konamiCode.join(''))) { rainProfiles() }})
This code listens for the Konami Code and then calls the rainProfiles
function.
Although the particle rain was cool its wasn't Strapi enough. Thats when I discovered this CodePen
I decided that I could replace all the dots with each GitHub profile image. So I did a good o'l copy pasta and went to work figuring out how to replace the dots with the profile images.
The way the CodePen works is it uses HTML Canvas to draw each particle over and over again. The CodePen was using text but I wanted to use the Strapi Logo.
In the particle constructor I stored which image the Particle should use. I initially wanted to randomly select the images but due to the laws of probability it caused to many duplicates to show up.
// Particle Constructorthis.image = new Image()if (profileIndex >= profiles.length) profileIndex = 0this.image.src = profiles[profileIndex++]
Then inside the Particle render function I clip the image to a circle and draw the image instead of using ctx.fillStyle
Particle.prototype.render = function () { // clip image to circle ctx.save() ctx.beginPath() ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2, true) ctx.closePath() ctx.clip() ctx.drawImage(this.image, this.x - this.r, this.y - this.r, this.r * 2, this.r * 2) ctx.restore() ctx.fillStyle = '#000'}
The CodePen used text to map the particles but I wanted to use the Strapi Logo. The good thing is all I had to do was swap out the text for an image, similarly to how I swapped out a color for an image for the particles.
const img = new Image()img.src = '/img/strapi.png'// Once the image is loaded, draw it on the canvasimg.onload = function () { const aspectRatio = img.width / img.height const padding = 300 const newWidth = ww - padding const newHeight = newWidth / aspectRatio // center the image ctx.drawImage(img, padding / 2, (wh - img.height) / 2, newWidth, newHeight) // Get the image data and create particles const data = ctx.getImageData(0, 0, ww, wh).data ctx.clearRect(0, 0, canvas.width, canvas.height) ctx.globalCompositeOperation = 'screen' particles = [] for (let i = 0; i < ww; i += Math.round(ww / 80)) { for (let j = 0; j < wh; j += Math.round(ww / 80)) { if (data[(i + j * ww) * 4 + 3] > 80) { particles.push(new Particle(i, j)) } } } amount = particles.length}
This code first loads the Strapi Logo then does some math to maintain the aspect ratio and add padding to each side. We then use ctx.getImageData()
which returns an array of all the pixel data currently rendered on the canvas.
We then want to split the canvas into a grid because if we drew a particle on every pixel your computer would probably catch on fire.
This i += Math.round(ww / 80)
will make a new box every 80px. this will go row by row until we have a grid. Then if the grid has more than 80 pixel in that grid then we want to want to draw a particle.
We now have a particle logo with images!
After this, Paul (A Strapi Team Member) suggested I turn it into a breakout game. Little did he know that that suggestion caused me to lose 20 hours of my life over the next 2 days. (jk ♥ you Paul)
So I went to work and found this guide which gave me a good starting point.
Good news is I already had a canvas so it was pretty easy to expand it.
But first I needed to organize my code putting all of this into 1 file would be a nightmare.
I created 5 classes
I am only going to go over the Ball, Bar and Main JS code as Firework, Particle and Game code is pretty simple. If you want to see all the code you can view my Pull Request
Then I had the main js file as before.
I've never really done game development before. I've made a couple games in LibGDX back in the day but I definitely never built a game from scratch without any utilities.
Rules: Each Object is responsible for it's own rendering. i.e ball will have a render function which will draw itself on the screen. Each Object is responsible for it's own physics. i.e ball should know if it's colliding into a wall and bounce off Each class should contain all of its objects. Instead of storing the objects in some variable it will be stored in a static variable in the class. This means if we want to get the particles all we need to do is Particle.particles and this will return an array of particles.
The ball is probably the most difficult class to code as the ball interacts with every object.
The ball has a few properties that you should know the first being x
and y
which is the current position of the ball and dx
and dy
which is change (delta) i.e how much x
and y
changes each frame.
ww
and wh
is the canvas width and canvas height. I probably should of renamed these but it was legacy code from the CodePen so I didn't want to do that refactor. (Again I'm lazy)
// bounce off the sides of the screenif (this.x + this.dx > this.ww - this.radius || this.x + this.dx < this.radius) { this.dx = -this.dx}// bounce off the top of the screenif (this.y + this.dy > this.wh - this.radius || this.y + this.dy < this.radius) { this.dy = -this.dy}
When we do this.x + this.dx
this corresponds to where the ball will be on the next frame. so we check if on the next frame it will be past the boundaries of the canvas and if it is we invert the delta causing the ball to bounce.
This code is a bit more complex as if you have every played breakout the bar can bounce the ball in different ways and make the ball change direction.
if ( this.y <= this.bar.y && // ball previous frame is above the bar this.y + this.dy >= this.bar.y && // ball next frame is below the bar this.x > this.bar.x - this.radius && this.x < this.bar.x + this.bar.barWidth + this.radius) { this.dy = Math.max(-this.dy - this.speedMargin, -this.speed) let barCenter = this.bar.x + this.bar.barWidth / 2 let ballCenter = this.x + this.radius let distanceFromCenter = barCenter - ballCenter // get distance from center as a percentage of the bar width from -1 to 1 let distanceFromCenterPercentage = distanceFromCenter / (this.bar.barWidth / 2) // dx should have a random speed within the speedMargin // get random number between -speedMargin and speedMargin let randomSpeed = Math.random() * this.speedMargin * 2 - this.speedMargin this.dx = this.speed * Math.min(Math.abs(distanceFromCenterPercentage), 0.5) + randomSpeed}
We first check if the ball will collide with the bar using the same logic as the wall but we use the bar's current position instead of a fixed position.
I then wanted to add some randomness to it to prevent the ball from getting stuck in a loop so we use a speedMargin
to add some randomness to the speed.
We then use the distance from the center of the bar and multiply that by our speed to change the x
velocity. The closer you are to the edge of the bar the faster it will go. With the slowest the ball can go is speed/2 + speedMargin
The game over code is simple we just check if the ball is below the bar then trigger game over
// canvas y is upside down hence the > and not <if (this.y > this.wh - 25) { game.gameOver = true game.active = false}
On each render we loop through all of the particles and see if the ball is colliding with it. We then can also check if which side we bounced off so we can change the direction accordingly.
for (let i = 0; i < particles.length; i++) { let particle = particles[i] if ( this.x + this.dx > particle.x - this.radius && this.x + this.dx < particle.x + this.radius && this.y + this.dy > particle.y - this.radius && this.y + this.dy < particle.y + this.radius ) { // bounce off of the particle depending on which side of the particle the ball hit Check for top bottom left and right if ( this.x + this.dx > particle.x - this.radius && this.x + this.dx < particle.x + this.radius && this.y + this.dy > particle.y - this.radius && this.y + this.dy < particle.y + this.radius ) { // bounce off of the particle depending on which side of the particle the ball hit // if it hits the side the keep th y velocity the same and change the x velocity // if it hits the top or bottom then keep the x velocity the same and change the y velocity if ( this.x + this.dx > particle.x - this.radius && this.x + this.dx < particle.x + this.radius ) { this.dy = -this.dy } else { this.dx = -this.dx } } // destroy the particle particle.destroy() // if there are no more particles then the game is over if (Particle.particles.length === 0) { game.gameOver = true game.active = false } }}
We then can finally move the ball by incrementing the x and y value by their deltas
this.x += this.dxthis.y += this.dythis.drawBall(ctx)
The bar is thankfully much simpler than the ball.
We can move the bar by arrow keys or the mouse using this code:
if (Game.leftPressed) { this.x -= this.movementSpeed}if (Game.rightPressed) { this.x += this.movementSpeed}if (Game.mouseMoveEvents > 100) { this.x = Game.mouse.x - this.barWidth / 2}
The reason why we check if the mouseMoveEvents is > 100 is that if you are using keyboard controls and you bump your mouse I didn't want the bar to move to a different part of the screen causing you to lose.
Every time you press a key it will reset the movement count which means if you want to switch back to the mouse you need to shake it around for a second before it will pick back up.
We then need to make sure the bar doesn't go off the screen
if (this.x < 0) { this.x = 0}if (this.x + this.barWidth > this.ww) { this.x = this.ww - this.barWidth}
We first do all the stuff I mentioned before with grabbing the GitHub images and drawing the particles to the screen.
You may have noticed there are some DOM elements that have been updated
We do this with some basic JS but also make sure to store the original value so we can reset everything back to normal
function setupDom() { document.querySelector('.navbar__items').appendChild(score) // change all h1 tags to say "Strapi Breakout - Press Enter To Start" document.querySelectorAll('h1').forEach((h1) => { // store the original text in the data-original-text attribute h1.setAttribute('data-original-text', h1.innerText) h1.innerText = 'Strapi Breakout' // add smaller instructions below in an h2 const h2 = document.createElement('h2') h2.innerText = 'Press Enter to Start \n Press Esc to Exit' h1.parentNode.insertBefore(h2, h1.nextSibling) }) //store the original title in the data-original-title attribute document.ogTitle = document.title // animate document title with a marquee saying "Strapi Breakout" with padding document.title = `Strapi Breakout - Press Enter to Start - Press Esc to Exit- ` // animate title let title = document.title let titleLength = title.length let titleIndex = 0 titleInterval = setInterval(() => { titleIndex++ if (titleIndex > titleLength) { titleIndex = 0 } document.title = title.slice(titleIndex) + title.slice(0, titleIndex) }, 200) // set scroll to top window.scrollTo(0, 0)}
We then need to create all the game objects
Particle.particles = []for (let i = 0; i < ww; i += Math.round(ww / 80)) { for (let j = 0; j < wh; j += Math.round(ww / 80)) { if (data[(i + j * ww) * 4 + 3] > 80) { Particle.particles.push(new Particle(i, j, ww, wh, game)) } }}for (let i = 0; i < 100; i++) { const x = Math.random() * ww const y = Math.random() * wh const size = Math.random() * 25 + 10 const color = `${Math.floor(Math.random() * 256)},${Math.floor(Math.random() * 256)},${Math.floor( Math.random() * 256 )}` Firework.fireworks.push(new Firework(x, y, size, color))}bar = new Bar(ww, wh)ball = new Ball(ww, wh, bar)window.bar = barwindow.ball = ball
I am not going to be going over the fireworks so if you want to see them I guess you will have to beat the game.
To render everything we have a recursive render function that uses requestAnimationFrame()
We first check that the game isn't running at more than 60 fps as game speed is tied to framerate. The proper way to do this is a tick system but I didn't see the point in implementing that in such a simple game. This does mean that if you have really low fps the game will run slower.
let then = Date.now();const fps = 60;const interval = 1000 / fps;function render(a) { // console.log(rendering) if (!game) return; // cap the framerate to 60fps const now = Date.now(); const delta = now - then; if (delta < interval || !rendering.value) { requestAnimationFrame(render); return; } //...
We then clear the canvas in anticipation of drawing a new frame
ctx.clearRect(0, 0, canvas.width, canvas.height)
Now we render all the game objects
for (let i = 0; i < Particle.particles.length; i++) { Particle.particles[i].render(ctx, game.active, game.gameOver)}bar?.render(ctx)ball?.render(ctx, game)
Because each object has its own render function we just call the render function and pass in the canvas 2d context and it will draw itself on the screen.
We then can use this if statement
if (game.gameOver && Particle.particles.length === 0)
to check if the game is won and draw render the fireworks and You Win text
for (let i = 0; i < Firework.fireworks.length; i++) { Firework.fireworks[i].update(ctx) Firework.fireworks[i].render(ctx) // Remove the firework from the array if it has finished exploding if (Firework.fireworks[i].isFinished()) { Firework.fireworks.splice(i, 1) i-- }}//...ctx.strokeText('You Win', ww / 2, wh / 2)ctx.fillText('You Win', ww / 2, wh / 2)//...ctx.strokeText(`Score: ${game.score}`, ww / 2, wh / 2 + 50)ctx.fillText(`Score: ${game.score}`, ww / 2, wh / 2 + 50)
Then if the game is over and the particles array is not empty that means the player lost.
else if (game.gameOver) { //... ctx.strokeText("Game Over", ww / 2, wh / 2); ctx.fillText("Game Over", ww / 2, wh / 2); /... ctx.strokeText(`Score: ${game.score}`, ww / 2, wh / 2 + 50); ctx.fillText(`Score: ${game.score}`, ww / 2, wh / 2 + 50);
We then have some other boring logic for resetting the game and such.
I export all of the game objects and classes to the chrome console so you can try and hack the game instead of playing it fairly. 😈😈😈
_____ __ _
/ ___/ / /_ _____ ____ _ ____ (_)
\__ \ / __// ___// __ `// __ \ / /
___/ // /_ / / / /_/ // /_/ // /
/____/ \__//_/ \__,_// .___//_/
/_/
____ __ __
/ __ ) _____ ___ ____ _ / /__ ____ __ __ / /_
/ __ |/ ___// _ \ / __ `// //_// __ \ / / / // __/
/ /_/ // / / __// /_/ // ,< / /_/ // /_/ // /_
/_____//_/ \___/ \__,_//_/|_| \____/ \__,_/ \__/
---------------------------------------------------
| Welcome to the Strapi Breakout Game!
| Made with ♥ by https://github.com/cpaczek
| Use the Arrow/AD keys or Mouse to move the paddle.
| Press Enter to start the game.
| Press Escape to exit the game.
---------------------------------------------------
| Secret Menu:
| Press P to toggle rendering (i.e pause the game).
| Change the speed by typing in the console: ball.speed = 15
| Exported Game Objects:
| - ball
| - bar
| - game
| - Particle (Class)
| - Firework (Class)
| - Game (Class)
| - Bar (Class)
| - Ball (Class)
---------------------------------------------------
Good Luck!
I hope you enjoyed this tutorial and learned something new. I had a lot of fun making this game and I hope you have fun playing it. I made this game over the course of 2 days so its not perfect but its good enough™ for an easter egg.
If you want to play you can go to docs.strapi.io and enter the Konami code on your keyboard to play the game. (Up, Up, Down, Down, Left, Right, Left, Right, B, A)