DEV Community

Diana Le
Diana Le

Posted on • Edited on

Creating Squid Game's Dalgona Using Canvas

I've been a front-end web developer for a while and yet I've only used HTML5's canvas element 1-2 times, and only in my spare time. Wes Bos has an Etch-a-Sketch game using canvas in his Beginner's JavaScript course, which if you never used canvas before like me, is a nice introduction to the cool things you can do with it.

After watching Squid Game on Netflix, I started thinking about if I could recreate any of those games in the browser.

Slight Spoilers ahead for Squid Game

View on Github

The obvious choice became Dalgona based on what I remembered about canvas and being able to draw freehand, which would allow the user to draw a shape - much like the show where the players have to meticulously cut out a candy shape perfectly. But not only would the user need to draw a shape, the shape would need to be loaded beforehand, the user would need to trace over to try to match, and at the very end there needed to be a way to compare the two and determine whether they were close.

At this point I had no idea where to start, but a quick search of "tracing games in canvas" resulted in this on-the-nose example called Letterpaint, which is a game where the user has to fill in a letter as close as possible.

This project was not the best idea for a canvas beginner. I'd made a goal of making either a Codepen or a Dev.to blog post once a week, but once I started this project, everything ground to a halt. I spent two whole weekends trying to figure out how to draw an umbrella - not just any umbrella - it had to be the one from the show for accuracy's sake.

What started as a fun idea became frustrating and I thought about giving up several times. I wondered was this the best way to use my coding time on the weekends? But curiosity won out in the end and I got the code working - it's not the prettiest and needs to be refactored - but I felt a great sense of accomplishment in getting it to work. And in a way it felt honest. Coding is hard and you can't always "learn HTML in a day." So I'm going to walk through not just how this game works, but my struggles and problem-solving I had to go through to get this finished.

Set Up Canvas

This is standard code whenever you use canvas. You'll want to set the drawing context, the width and height, and also the line style.

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');

/* Set up the size and line styles of the canvas */
function setupCanvas() {
   canvas.height = 370;
   canvas.width = 370;
   canvas.style.width = `${canvas.width}px`;
   canvas.style.height = `${canvas.height}px`;
   ctx.lineWidth = 12;
   ctx.lineCap = 'round';
}
Enter fullscreen mode Exit fullscreen mode

Draw the Shapes

This is where being a novice to canvas became a huge obstacle. I had never tried to draw any shapes either using SVGs or canvas, so trying to brute-force my way through all of these was quite the challenge.

The Triangle

This was the first shape I attempted, and the main struggle I had here was actually due more to geometry than coding. If you're trying to draw a polygon this is very straightforward. You set a starting point consisting of x and y coordinates, then tell the canvas to draw a line to another set of coordinates, and so on, for a total of 3 separate coordinates to make a triangle.

I initially tried to make this an exact equilateral triangle, but rather than try to look up the geometry formulas I decided to just manually test the coordinates and settled on what looked "right" without worrying about making it perfect.

/* Triangle shape */
function drawTriangle() {
   ctx.strokeStyle = 'rgb(66, 10, 0)';
   ctx.beginPath();
   ctx.moveTo(185, 85);
   ctx.lineTo(285, 260);
   ctx.lineTo(85, 260);
   ctx.closePath();
   ctx.stroke();
}
Enter fullscreen mode Exit fullscreen mode

The Circle

Circles are actually pretty easy to draw. Using the built-in arc() method, you can just specify the center of the circle and then add another parameter for the radius. The final two parameters will always be the same if you're making a complete circle.

function drawCircle() {
   ctx.strokeStyle = 'rgb(66, 10, 0)';
   ctx.beginPath();
   ctx.arc(185, 185, 100, 0 * Math.PI, 2 * Math.PI);
   ctx.closePath();
   ctx.stroke();
}
Enter fullscreen mode Exit fullscreen mode

The Star

I briefly tried to draw this as I did the triangle by setting manual coordinates, but then gave up and found someone had coded a dynamic function specifically to draw stars where the number of points can be specified. (I love open source).

function drawStar() {
   ctx.strokeStyle = 'rgb(66, 10, 0)';

   let rot = Math.PI / 2 * 3;
   let x = 185;
   let y = 185;
   let cx = 185;
   let cy = 185;
   const spikes = 5;
   const outerRadius = 120;
   const innerRadius = 60;
   const step = Math.PI / 5;

   ctx.strokeSyle = "#000";
   ctx.beginPath();
   ctx.moveTo(cx, cy - outerRadius)
   for (i = 0; i < spikes; i++) {
       x = cx + Math.cos(rot) * outerRadius;
       y = cy + Math.sin(rot) * outerRadius;
       ctx.lineTo(x, y)
       rot += step

       x = cx + Math.cos(rot) * innerRadius;
       y = cy + Math.sin(rot) * innerRadius;
       ctx.lineTo(x, y)
       rot += step
   }
   ctx.lineTo(cx, cy - outerRadius)
   ctx.closePath();
   ctx.stroke();
}
Enter fullscreen mode Exit fullscreen mode

The Umbrella

Oh Gi-Hun, I feel your pain. I went about this many different ways. I downloaded open-source vector software to try to manually draw an umbrella and then import as an SVG image to canvas but I couldn't figure out how to draw curves properly, and learning a program to draw one shape in this game seemed like overkill.

I went through many attempts to draw this manually like the triangle but the lineTo() works for polygons and not curves. Then I had an epiphany that there already existed a method to draw curves - the arc() method. Wasn't the umbrella merely a set of multiple different-sized curves and straight lines - both of which I had already done? I patted myself on the back for figuring this out.

...Unfortunately, it wasn't so easy in practice. The first arc - the main overall parasol was easy enough, I had to slightly modify the arc() method so that it was a semi-circle instead of a complete circle, and then alter the default direction. But once I started adding additional arcs, all of the subsequent ones started to close the path under the arc halfway with a straight horizontal line:

ctx.beginPath();
// Umbrella parasol
ctx.arc(200, 180, 120, 0*Math.PI, 1 * Math.PI, true); 
// Umbrella curves
ctx.moveTo(105, 180);
ctx.arc(105, 180, 25, 0*Math.PI, 1 * Math.PI, true);
Enter fullscreen mode Exit fullscreen mode

I could not figure this out. If I removed the first parasol arc, this horizontal line disappeared on the 2nd arc, but then if I added another one that issue would happen again. I went through a process of trial-and-error with beginPath() and stroke() and finally, FINALLY got it working by creating a separate subfunction for all the individual arcs:

/* Draw individual arcs */
function drawArc(x, y, radius, start, end, counterClockwise = true) {
   ctx.beginPath();
   ctx.arc(x, y, radius, start * Math.PI, end * Math.PI, counterClockwise);
   ctx.stroke();
}
Enter fullscreen mode Exit fullscreen mode

Why did this work as opposed to the original function? Honestly I have no idea. Maybe the moveTo() was causing it to draw the lines. At this point I left it as is and told myself not to modify or else risk breaking it all over again. I committed the changes immediately to Github and felt incredible joy that I got it working. Incredible joy at figuring out how to draw an umbrella. It's the little things sometimes.

/* Umbrella Shape */
function drawUmbrella() {
   ctx.strokeStyle = 'rgb(66, 10, 0)';

   /* Draw individual arcs */
   drawArc(185, 165, 120, 0, 1); // large parasol
   drawArc(93, 165, 26, 0, 1);
   drawArc(146, 165, 26, 0, 1);
   drawArc(228, 165, 26, 0, 1);
   drawArc(279, 165, 26, 0, 1);

   /* Draw handle */
   ctx.moveTo(172, 165);
   ctx.lineTo(172, 285);
   ctx.stroke();
   drawArc(222, 285, 50, 0, 1, false);
   drawArc(256, 285, 16, 0, 1);
   drawArc(221, 286, 19, 0, 1, false);
   ctx.moveTo(202, 285);
   ctx.lineTo(202, 169);
   ctx.stroke();
}
Enter fullscreen mode Exit fullscreen mode

Set Up the User Paint Functionality

There are a couple things here that make this more complicated than if you just wanted to let the user paint whatever on the canvas. In order for the painting to be a continuous line and not splotchy like the default behavior of canvas, then we need to connect to the previous x and y coordinates of the user.

function paint(x, y) {
  ctx.strokeStyle = 'rgb(247, 226, 135)';
  ctx.beginPath();
  /* Draw a continuous line */
  if (prevX > 0 && prevY > 0) {
    ctx.moveTo(prevX, prevY);
  }
  ctx.lineTo(x, y);
  ctx.stroke();
  ctx.closePath();
  prevX = x;
  prevY = y;
}
Enter fullscreen mode Exit fullscreen mode

Some other functionality that is not detailed here: the user should only draw while holding down on the mouse in order to give more control over cutting the shape and not automatically paint when moving the cursor to the drawing to begin with. Also to make this more difficult, the user is only allowed to attempt one continuous motion - once the user lets go of the mouse, this triggers the end game. So they must complete the tracing in one continuous motion.

Compare the User Input with the Shape Based on Color

Now we have the shapes for the candies, and the user can draw on top of the shape, but how do we determine if the user has traced the shape accurately? The first thing I thought about was somehow finding out the coordinates of each pixel in the drawing and then comparing with the coordinates of the shape the user traced. This is where the logic of the Letterpaint game came in again to make things much easier.

The shapes all use the same color, and the user painting uses a different color. So what of instead of trying to compare coordinates we just compared the number of pixels of each of the colors to each other? If the user has managed to trace over the shape perfectly, then the number of painted pixels will equal the number of shape pixels and thus equal 1. If the user only paints half of the shape perfectly, then the ratio will be 50%. To do this we have a function that gets the pixel data using the method getImageData) which returns an object containing the pixel data.

function getPixelColor(x, y) {
   const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
   let index = ((y * (pixels.width * 4)) + (x * 4));
   return {
      r:pixels.data[index],
      g:pixels.data[index + 1],
      b:pixels.data[index + 2],
      a:pixels.data[index + 3]
   };
}
Enter fullscreen mode Exit fullscreen mode

So for every function that draws a shape, it will need to call the function to get the number of pixels:

function drawCircle() {
   /* Draw circle code... */

   /* Get pixels of shape */
   pixelsShape = getPixelAmount(66, 10, 0);
}
Enter fullscreen mode Exit fullscreen mode

But wait a minute, does this mean the user can just draw the exact same shape without actually trying to trace? Or could the user just squiggle a blob of pixels that is the same amount as the drawing? Yep, so to prevent that we actually need to add a check on the paint function to make sure the user doesn't veer off the shape too much:

let color = getPixelColor(x, y);
if (color.r === 0 && color.g === 0 && color.b === 0) {
  score.textContent = `FAILURE - You broke the shape`;
  brokeShape = true;
} 
Enter fullscreen mode Exit fullscreen mode

Again, we're checking the pixels and if the r, g, and b is 0 (the user is painting on a part of the canvas with nothing on it), then they have automatically failed the game. Instant game over just like the show.

There is some slight bugginess with this that I haven't quite been able to figure out. I logged out the r, g, and b values to the console when drawing and on rare occasions instead of r equaling 66 (the color of the shape), it returned 65, or other very slight variances. So the true pixel amount of each of the colors is likely not 100% accurate.

Determine Win State

We're comparing the pixels between the drawings and the user painting, and we are only checking if the user hasn't broken the shape already, and if they score a certain percentage, then they win.

function evaluatePixels() {
   if (!brokeShape) {
      const pixelsTrace = getPixelAmount(247, 226, 135);
      let pixelDifference = pixelsTrace / pixelsShape;
      /* User has scored at last 50% */
      if (pixelDifference >= 0.75 && pixelDifference <= 1) {
         score.textContent = `SUCCESS - You scored ${Math.round(pixelDifference * 100)}%`;
      } else {
         score.textContent = `FAILURE - You cut ${Math.round(pixelDifference * 100)}%`;
      }
   }
}
Enter fullscreen mode Exit fullscreen mode

Reset Everything

There's a lot of small functionality going on here. Basically we want to clear everything on restarting the games: clear the shape, clear any previous x and y coordinates, clear the results, clear any stored pixel data, and reset any game states.

function clearCanvas() {
   ctx.clearRect(0, 0, canvas.width, canvas.height);
   gameStart.classList.remove('hidden');
   mouseDown = false;
   startedTurn = false;
   brokeShape = false;
   score.textContent = '';
   prevX = '';
   prevY = '';
   pixelsShape = 0;
}
Enter fullscreen mode Exit fullscreen mode

Resize Everything

Here's a cardinal rule of web development. Make sure you know what screen sizes your site needs to run on before coding. I originally set up the canvas size for testing just to make sure I could draw a triangle. Then I realized this game makes at least as much sense on a smartphone as well as desktop and resized to 400 pixels so it was viewable on my Pixel. Then what do you suppose happened to all my draw functions? They were completely the wrong size and/or no longer centered, so I had to go back and adjust the coordinates for all of them. Luckily I hadn't figured out the umbrella draw function yet.

...Until I realized I should resize the canvas again for a 2nd time because some of the previous iPhones have resolutions smaller than 400 pixels, so the final size of the canvas was 370 pixels. Fortunately for the umbrella, it was a straightforward matter of adjusting the pixels and coordinates and taking into account the adjusted diameters as well.

Testing on Mobile

One final, tiny wrinkle as I was just about to publish: IT DIDN'T WORK ON MOBILE. I was testing in the browser using the mobile emulator and had to turn off the "drag to scroll" and thought... wait a minute. Then I actually tested after publishing to Github, and yep, this doesn't work out of the box on touch devices because touching the screen scrolls the browser instead of drawing on the actual canvas.

Someone else's tutorial came to the rescue again. Basically we need to map every mouse event handler to its touch-equivalent, AND prevent the screen from scrolling at all when it's a touchscreen. This meant I had to move the instructions from underneath the canvas to the initial shape select popup (to make scrolling unnecessary on mobile), and I had to increase the canvas line width from 12 to 15 since it felt a little bit TOO thin on mobile. Also the "breaking the shape" is much more generous on mobile unintentionally somehow, which means the user is able to paint outside the shape a lot more, so that meant adding a validation check to fail the user if they score over 100% as well. At this point I felt it was time to let other people start playing with it.

Conclusion

Although this experience was frustrating at times, this project is an example of why I love web development. You can take a representation of a design, an idea, a concept and make it into something interactive in the browser for everyone to play with. The important part is figuring out how to get something to work; the code can always be cleaned up afterwards. Once I have more experience with canvas, it'll be fun to go back and improve things in this project.

Top comments (8)

Collapse
 
dudeactual profile image
masspopcorn

Got a 100%

Collapse
 
darkwiiplayer profile image
π’ŽWii πŸ³οΈβ€βš§οΈ

Doesn't work when you scroll though ;)

Collapse
 
dianale profile image
Diana Le

Yeah there's some bugginess around that, like it throws off where canvas registers even though I've set the bounding container

Collapse
 
harishash profile image
Haris#

Damn this is cool!

Collapse
 
ruppysuppy profile image
Tapajyoti Bose

A rather interesting idea!

Collapse
 
foxcoding1006 profile image
Fox-coding-1006

how to play it ?

Collapse
 
dianale profile image
Diana Le

You should be able to either use your cursor or finger on touchscreen to trace the shape

Collapse
 
foxcoding1006 profile image
Fox-coding-1006

thanks