DEV Community

Cover image for Build a Simple Game in Vanilla JS With the Drag and Drop API
Jscrambler for Jscrambler

Posted on • Originally published at blog.jscrambler.com

Build a Simple Game in Vanilla JS With the Drag and Drop API

The JavaScript language lives in a browser. Actually, let's rephrase that: a web browser has a separate part inside of it, called the JavaScript engine. This engine can understand and run JS code.

There are many other separate parts that, altogether, make up the browser. These parts are different Browser APIs, also known as Web APIs. The JS engine is there to facilitate the execution of the JS code we write. Writing JS code is a way for us (developers) to access various functionalities that exist in the browser and that are exposed to us via Browser APIs.

What Is a Web API?

Web APIs are known as "browser features". Arguably, the most popular of these various browser features⁠—at least for JavaScript developers⁠—is the browser console. The Console API allows us to log out the values of variables in our JavaScript code. Thus, we can manipulate values in our JS code and log out these values to verify that a specific variable holds a specific (expected) value at a certain point in the thread of execution, which is great for debugging. If you've spent any significant amount of time with the JS language, this should all be pretty familiar.

What some beginner JavaScript developers do not comprehend is the big picture of the browser having a large number of these "browser features" built-in⁠—and accessible to us via various JavaScript “facade” methods: methods that look like they’re just a part of the language itself, but are actually “facades” for features outside of the JS language itself. Some examples of widely used Web APIs are the DOM API, the Canvas API, the Fetch API, etc.

The JavaScript language is set up in such a way that we can't immediately infer that the functionality we're using is in fact a browser feature. For example, when we say:

let heading = document.getElementById('main-heading');
Enter fullscreen mode Exit fullscreen mode

... we're actually hooking into a browser feature⁠—but there's no way of knowing this since it looks like regular JS code.

The Drag and Drop Web API

To understand how the Drag and Drop API works, and to effectively use it, all we have to do is know some basic concepts and methods it needs. Similar to how most front-end developers are familiar with the example from the previous section (namely, document.getElementById), we need to learn:

  • the basic concepts of the Drag and Drop Web API;
  • at least a few basic methods and commands.

The first important concept related to the Drag and Drop API is the concept of the source and target elements.

Source and Target Elements

There are built-in browser behaviors that determine how certain elements will behave when a user clicks and drags them on the viewport. For example, if we try click-dragging the intro image of this very tutorial, we'll see a behavior it triggers: the image will be displayed as a semi-transparent thumbnail, on the side of our mouse pointer, following the mouse pointer for as long as we hold the click. The mouse pointer also changes to the following style:

cursor: grabbing;
Enter fullscreen mode Exit fullscreen mode

We've just shown an example of an element becoming a source element for a drag-and-drop operation. The target of such an operation is known as the target element.

Before we cover an actual drag and drop operation, let's have a quick revision of events in JS.

Events in JS: A Quick Revision

We could go as far as saying that events are the foundation on which all our JavaScript code rests. As soon as we need to do something interactive on a web page, events come into play.

In our code, we listen for: mouse clicks, mouse hovers (mouseover events), scroll events, keystroke events, document loaded events...

We also write event handlers that take care of executing some JavaScript code to handle these events.

We say that we listen for events firing and that we write event handlers for the events being fired.

Describing a Drag and Drop Operation, Step By Step

The HTML and CSS

Let's now go through a minimal drag and drop operation. We'll describe the theory and concepts behind this operation as we go through each step.

The example is as easy as can be: there are two elements on a page. They are styled as boxes. The first one is a little box and the second one is a big box.

To make things even easier to comprehend, let's "label" the first box as "source", and the second one as "target":

<div id="source">Source</div>
<div id="target">Target</div>
<style>
    #source {
        background: wheat;
        width: 100px;
        padding: 20px;
        text-align: center;
    }

#target {
    background: #abcdef;
    width: 360px;
    height: 180px;
    padding: 20px 40px;
    text-align: center;
    margin-top: 50px;
    box-sizing: border-box;
}
</style>
Enter fullscreen mode Exit fullscreen mode

A little CSS caveat above: to avoid the added border width increasing the width of the whole target div, we’ve added the CSS property-value pair of box-sizing: border-box to the #target CSS declaration. Thus, the target element has consistent width, regardless of whether our drag event handlers are running or not.

The output of this code is fairly straightforward:

jscrambler-blog-build-a-game-in-js-1

Convert the “Plain” HTML Element Into a Drag and Drop Source Element

To do this, we use the draggable attribute, like so:

<div id="source" draggable="true">Source</div>
Enter fullscreen mode Exit fullscreen mode

What this little addition does is changing the behavior of the element. Before we added the draggable attribute, if a user click-dragged on the Source div, they'd likely just highlight the text of the div (i.e the word "Source")⁠—as if they were planning to select the text before copying it.

However, with the addition of the draggable attribute, the element changes its behavior and behaves exactly like a regular HTML img element — we even get that little grabbed cursor⁠—giving an additional signal that we've triggered the drag-and-drop functionality.

Capture Drag and Drop Events

There are 8 relevant events in this API:

  1. drag
  2. dragstart
  3. dragend
  4. dragover
  5. dragenter
  6. dragleave
  7. drop
  8. dragend

During a drag and drop operation, a number of the above events can be triggered: maybe even all of them. However, we still need to write the code to react to these events, using event handlers, as we will see next.

Handling the Dragstart and Dragend Events

We can begin writing our code easily. To specify which event we’re handling, we’ll just add an on prefix.

For example, in our HTML code snippet above, we’ve turned a “regular” HTML element into a source element for a drag-and-drop operation. Let’s now handle the dragstart event, which fires as soon as a user has started dragging the source element:

let sourceElem = document.getElementById('source');
sourceElem.addEventListener('dragstart', function (event) {
    confirm('Are you sure you want to move this element?');
})
Enter fullscreen mode Exit fullscreen mode

All right, so we’re reacting to a dragstart event, i.e. we’re handling the dragstart event.

Now that we know we can handle the event, let’s react to the event firing by changing the styles of the source element and the target element.

let sourceElem = document.getElementById('source');
let targetElem = document.getElementById('target');
sourceElem.addEventListener('dragstart', function (event) {
    event.currentTarget.style = "opacity:0.3";
    targetElem.style = "border: 10px dashed gray;";
})
Enter fullscreen mode Exit fullscreen mode

Now, we’re handling the dragstart event by making the source element see-through, and the target element gets a big dashed gray border so that the user can more easily see what we want them to do.

It’s time to undo the style changes when the dragend event fires (i.e. when the user releases the hold on the left mouse button):

sourceElem.addEventListener('dragend', function (event) {
    sourceElem.style = "opacity: 1";
    targetElem.style = "border: none";
})
Enter fullscreen mode Exit fullscreen mode

Here, we’ve used a slightly different syntax to show that there are alternative ways of updating the styles on both the source and the target elements. For the purposes of this tutorial, it doesn’t really matter what kind of syntax we choose to use.

Handling the Dragover and Drop Events

It’s time to deal with the dragover event. This event is fired from the target element.

targetElem.addEventListener('dragover', function (event) {
    event.preventDefault();
});
Enter fullscreen mode Exit fullscreen mode

All we’re doing in the above function is preventing the default behavior (which is opening as a link for specific elements). In a nutshell, we’re setting the stage for being able to perform some operation once the drop event is triggered.

Here’s our drop event handler:

targetElem.addEventListener('drop', function (event) {
    console.log('DROP!');
})
Enter fullscreen mode Exit fullscreen mode

Currently, we’re only logging out the string DROP! to the console. This is good enough since it’s proof that we’re going in the right direction.


Sidenote: notice how some events are emitted from the source element, and some other events are emitted from the target element. Specifically, in our example, the sourceElem element emits the dragstart and the dragend events, and the targetElem emits the dragover and drop events.

Next, we’ll use the dataTransfer object to move the source element onto the target element.

Utilize The dataTransfer Object

The dataTransfer object “lives” in an instance of the Event object⁠—which is built-in to any event. We don’t have to “build” the event object⁠—we can simply pass it to the anonymous event handler function⁠—since functions are “first-class citizens” in JS (meaning: we can pass them around like any other value)⁠—this allows us to pass anonymous functions to event handlers, such as the example we just saw in the previous section.

In that piece of code, the second argument we passed to the addEventListener() method is the following anonymous function:

function(event) {
  console.log('DROP!');
}
Enter fullscreen mode Exit fullscreen mode

The event argument is a built-in object, an instance of the Event object. This event argument comes with a number of properties and methods, including the dataTransfer property⁠, which itself is an object.

In other words, we have the following situation (warning: pseudo-code ahead!):

event: {
,
dataTransfer: {},
stopPropagation: function(){},
preventDefault: function(){},
,
,
}
Enter fullscreen mode Exit fullscreen mode

The important thing to conclude from the above structure is that the event object is just a JS object holding other values, including nested objects and methods. The dataTransfer object is just one such nested object, which comes with its own set of properties/methods.

In our case, we’re interested in the setData() and getData() methods on the dataTransfer object.

The setData() and getData() Methods on The dataTransfer Object

To be able to successfully “copy” the source element onto the target element, we have to perform a few steps:

  1. We need to hook into an event handler for an appropriate drag-and-drop related event, as it emits from the source object;
  2. Once we’re hooked into that specific event (i.e. once we’ve completed the step above), we’ll need to use the event.dataTransfer.setData() method to pass in the relevant HTML⁠—which is, of course, the source element;
  3. We’ll then hook into an event handler for another drag-and-drop event⁠—this time, an event that’s emitting from the target object;
  4. Once we’re hooked into the event handler in the previous step, we’ll need to get the data from step two, using the following method: event.dataTransfer.getData().

That’s all there is to it! To reiterate, we’ll:

  1. Hook into the dragstart event and use the event.dataTransfer.setData() to pass in the source element to the dataTransfer object;
  2. Hook into the drop event and use the event.dataTransfer.getData() to get the source element’s data from the dataTransfer object.

So, let’s hook into the dragstart event handler and get the source element’s data:

sourceElem.addEventListener('dragstart', function(event) {
event.currentTarget.style="opacity:0.3";
targetElem.style = "border: 10px dashed gray;";
event.dataTransfer.setData('text', event.target.id);
})
Enter fullscreen mode Exit fullscreen mode

Next, let’s pass this data on to the drop event handler:

targetElem.addEventListener('drop', function(event) {
console.log('DROP!');
event.target.appendChild(document.getElementById(event.dataTransfer.getData('text')));
})
Enter fullscreen mode Exit fullscreen mode

We could rewrite this as:

targetElem.addEventListener('drop', function(event) {
console.log('DROP!');
const sourceElemData = event.dataTransfer.getData('text');
const sourceElemId = document.getElementById(sourceElemData);
event.target.appendChild(sourceElemId);
})
Enter fullscreen mode Exit fullscreen mode

Regardless of how we decide to do this, we’ve completed a simple drag-and-drop operation, from start to finish.

Next, let’s use the Drag and Drop API to build a game.

Write a Simple Game Using the Drag and Drop API

In this section, we’ll build a very, very, simple game. We’ll have a row of spans with jumbled lyrics to a famous song.

The user can now drag and drop the words from the first row onto the empty slots in the second row, as shown below.

jscrambler-blog-build-a-game-in-js-2

The goal of the game is to place the lyrics in the correct order.

Let’s begin by adding some HTML structure and CSS styles to our game.

Adding The game’s HTML and CSS

<h1>Famous lyrics game: Abba</h1>
<h2>Instruction: Drag the lyrics in the right order.</h2>
<div id="jumbledWordsWrapper">
  <span id="again" data-source-id="again" draggable="true">again</span>
  <span id="go" data-source-id="go" draggable="true">go</span>
  <span id="I" data-source-id="I" draggable="true">I</span>
  <span id="here" data-source-id="here" draggable="true">here</span>
  <span id="mia" data-source-id="mia" draggable="true">mia</span>
  <span id="Mamma" data-source-id="Mamma" draggable="true">Mamma</span
</div>
<div id="orderedWordsWrapper">
  <span data-target-id="Mamma"></span>
  <span data-target-id="mia"></span>
  <span data-target-id="here"></span>
  <span data-target-id="I"></span>
  <span data-target-id="go"></span>
  <span data-target-id="again"></span>
</div>
Enter fullscreen mode Exit fullscreen mode

The structure is straightforward. We have the static h1 and h2 tags. Then, we have the two divs:

  • jumbledWordsWrapper, and
  • orderedWordsWrapper

Each of these wrappers holds a number of span tags: one span tag for each word. The span tags in the orderedWordsWrapper don’t have any text inside, they’re empty.

We’ll use CSS to style our game, as follows:

body {
  padding: 40px;
}
h2 {
  margin-bottom: 50px;
}
#jumbledWordsWrapper span {
  background: wheat;
  box-sizing: border-box;
  display: inline-block;
  width: 100px;
  height: 50px;
  padding: 15px 25px;
  margin: 0 10px;
  text-align: center;
  border-radius: 5px;
  cursor: pointer;  
}
#orderedWordsWrapper span {
  background: #abcdef;
  box-sizing: border-box;
  text-align: center;
  margin-top: 50px;
}
Enter fullscreen mode Exit fullscreen mode

Next, we’ll add some behavior to our game using JavaScript.

Adding Our Game’s JavaScript Code

We’ll start our JS code by setting a couple of variables and logging them out to make sure we’ve got proper collections:

const jumbledWords = document.querySelectorAll('#jumbledWordsWrapper > span');
const orderedWords = document.querySelectorAll('#orderedWordsWrapper > span');
console.log('jumbledWords: ', jumbledWords);
console.log('orderedWords: ', orderedWords);
Enter fullscreen mode Exit fullscreen mode

The output in the console is as follows:

"jumbledWords: " // [object NodeList] (6)
["<span/>","<span/>","<span/>","<span/>","<span/>","<span/>"]
"orderedWords: " // [object NodeList] (6)
["<span/>","<span/>","<span/>","<span/>","<span/>","<span/>"]
Enter fullscreen mode Exit fullscreen mode

Now that we’re sure that we’re capturing the correct collections, let’s add an event listener on each of the members in the two collections.

On all the source elements, we’ll add methods to handle the dragstart event firing:

jumbledWords.forEach(el => {
  el.addEventListener('dragstart', dragStartHandler);
})
function dragStartHandler(e) {
  console.log('dragStartHandler running');
  e.dataTransfer.setData('text', e.target.getAttribute('data-source-id'));
  console.log(e.target);
}
Enter fullscreen mode Exit fullscreen mode

On all the target elements, we’ll add methods to handle all the relevant drag-and-drop events, namely:

  • dragenter
  • dragover
  • dragleave
  • drop

The code that follows should be already familiar:

orderedWords.forEach(el => {
  el.addEventListener('dragenter', dragEnterHandler);
  el.addEventListener('dragover', dragOverHandler);
  el.addEventListener('dragleave', dragLeaveHandler);
  el.addEventListener('drop', dropHandler);
})

function dragEnterHandler(e) {
  console.log('dragEnterHandler running');
}

function dragOverHandler(e) {
  console.log('dragOverHandler running');
  event.preventDefault();
}

function dragLeaveHandler(e) {
  console.log('dragLeaveHandler running');
}

function dropHandler(e) {
  e.preventDefault();

  console.log('dropHandler running');

  const dataSourceId = e.dataTransfer.getData('text'); 
  const dataTargetId = e.target.getAttribute('data-target-id');
  console.warn(dataSourceId, dataTargetId);

  if(dataSourceId === dataTargetId) {
    console.log(document.querySelector([dataTargetId]));
    e.target.insertAdjacentHTML('afterbegin', dataSourceId);
  }
}
Enter fullscreen mode Exit fullscreen mode

In the dropHandler() method, we’re preventing the default way that the browser handles the data that comes in. Next, we’re getting the dragged element’s data and we’re saving it in dataSourceId, which will be the first part of our matching check. Next, we get the dataTargetId so that we can compare if it is equal to the dataSourceId.

If dataSouceId and dataTargetId are equal, that means that our custom data attributes hold matching values, and thus we can complete the adding of the specific source element’s data into the specific target element’s HTML.

Adding CSS Code For Better UX

Let’s start by inspecting the complete JS code, made slimmer by removal of all redundant console.log() calls.

const jumbledWords = document.querySelectorAll('#jumbledWordsWrapper > span');
const orderedWords = document.querySelectorAll('#orderedWordsWrapper > span');

jumbledWords.forEach(el => {
  el.addEventListener('dragstart', dragStartHandler);
})
orderedWords.forEach(el => {
  el.addEventListener('dragenter', dragEnterHandler);
  el.addEventListener('dragover', dragOverHandler);
  el.addEventListener('dragleave', dragLeaveHandler);
  el.addEventListener('drop', dropHandler);
})

function dragStartHandler(e) {
  e.dataTransfer.setData('text', e.target.getAttribute('data-source-id'));
}

function dragEnterHandler(e) {
}

function dragOverHandler(e) {
  event.preventDefault();
}

function dragLeaveHandler(e) {
}

function dropHandler(e) {
  e.preventDefault();

  const dataSourceId = e.dataTransfer.getData('text'); 
  const dataTargetId = e.target.getAttribute('data-target-id');

  if(dataSourceId === dataTargetId) {
    e.target.insertAdjacentHTML('afterbegin', dataSourceId);
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can verify above, we’ve removed all the console.log() invocations, and thus some of our event handler functions are now empty.

That means these functions are ready to receive corresponding CSS code updates. Additionally, due to the updates in style to the dragStartHandler() method, we’ll also need to add a brand new source element’s event listener for the dragend event.

We’ll start by adding another event listener to the jumbledWords collection:

jumbledWords.forEach(el => {
  el.addEventListener('dragstart', dragStartHandler);
  el.addEventListener('dragend', dragEndHandler);
})
Enter fullscreen mode Exit fullscreen mode

And we’ll update the two event handler function definitions too:

function dragStartHandler(e) {
  e.dataTransfer.setData('text', e.target.getAttribute('data-source-id'));
  e.target.style = 'opacity: 0.3';
}
function dragEndHandler(e) {
  e.target.style = 'opacity: 1';
}
Enter fullscreen mode Exit fullscreen mode

Next, we’ll update the styles inside the dragEnterhandler() and the dragLeaveHandler() methods.

function dragEnterHandler(e) {
  e.target.style = 'border: 2px dashed gray; box-sizing: border-box; background: whitesmoke';
}
function dragLeaveHandler(e) {
  e.target.style = 'border: none; background: #abcdef';
}
Enter fullscreen mode Exit fullscreen mode

We’ll also go around some styling issues by updating the if condition inside the dropHandler() method:

  if(dataSourceId === dataTargetId) {
    e.target.insertAdjacentHTML('afterbegin', dataSourceId);
    e.target.style = 'border: none; background: #abcdef';
    e.target.setAttribute('draggable', false);
  }
Enter fullscreen mode Exit fullscreen mode

Preventing Errors

We’ve set up our JS code so that it checks if the values are matching for the data-source-id of the jumbledWordsWrapper div and the data-target-id of the orderedWordsWrapper div.

This check in itself prevents us from dragging any other word onto the correct place⁠—except for the matching word.

However, we have a bug: there’s no code preventing us from dragging the correct word into the same span inside the orderedWordsWrapper multiple times.

Here’s an example of this bug:

Obviously, this is a bug that we need to fix. Luckily, the solution is easy: we’ll just get the source element’s data-source-id, and we’ll use it to build a string which we’ll then use to run the querySelector on the entire document. This will allow us to find the one source span tag whose text node we used to pass it to the correct target slot. Once we do that, all we need to do is set the draggable attribute to false (on the source span element)⁠, thus preventing the already used source span element from being dragged again.

  if(dataSourceId === dataTargetId) {
    e.target.insertAdjacentHTML('afterbegin', dataSourceId);
    e.target.style = 'border: none; background: #abcdef';

    let sourceElemDataId = 'span[data-source-id="' + dataSourceId + '"]';
    let sourceElemSpanTag = document.querySelector(sourceElemDataId);
Enter fullscreen mode Exit fullscreen mode

Additionally, we can give our source element the styling to indicate that it’s no longer draggable. A nice way to do it is to add another attribute: a class attribute. We can do this with setAttribute syntax, but here’s another approach, using Object.assign():

    Object.assign(sourceElemSpanTag, {
      className: 'no-longer-draggable',
    });
Enter fullscreen mode Exit fullscreen mode

The above syntax allows us to set several attributes, and thus we can also set draggable to false as the second entry:

    Object.assign(sourceElemSpanTag, {
      className: 'no-longer-draggable',
      draggable: false,
    });
Enter fullscreen mode Exit fullscreen mode

Of course, we also need to update the CSS with the no-longer-draggable class:

.no-longer-draggable {
  cursor: not-allowed !important;
  background: lightgray !important;
  opacity: 0.5 !important;
}
Enter fullscreen mode Exit fullscreen mode

We have an additional small bug to fix: earlier in the tutorial, we’ve defined the dragEnterHandler() and the dragLeaveHandler() functions. The former function sets the styles on the dragged-over target to a dotted border and a pale background, which signals a possible drop location. The latter function reverts these styles to border: none; background: #abcdef. However, our bug occurs if we drag and try to drop a word into the wrong place. This happens because the dragEnterHandler() event handler gets called as we drag over the wrong word, but since we never trigger the dragLeaveHandler()⁠—instead, we triggered the dropHandler() function⁠—the styles never get reverted.

The solution for this is really easy: we’ll just run the dragLeaveHandler() at the top of the dropHandler() function definition, right after the e.preventDefault(), like this:

function dropHandler(e) {
  e.preventDefault();
  dragLeaveHandler(e); 
Enter fullscreen mode Exit fullscreen mode

Now our simple game is complete!

Here’s the full, completed JavaScript code:

const jumbledWords = document.querySelectorAll('#jumbledWordsWrapper > span');
const orderedWords = document.querySelectorAll('#orderedWordsWrapper > span');

jumbledWords.forEach(el => {
  el.addEventListener('dragstart', dragStartHandler);
  el.addEventListener('dragend', dragEndHandler);
})
orderedWords.forEach(el => {
  el.addEventListener('dragenter', dragEnterHandler);
  el.addEventListener('dragover', dragOverHandler);
  el.addEventListener('dragleave', dragLeaveHandler);
  el.addEventListener('drop', dropHandler);
})

function dragStartHandler(e) {
  e.dataTransfer.setData('text', e.target.getAttribute('data-source-id'));
  e.target.style = 'opacity: 0.3';
}
function dragEndHandler(e) {
  e.target.style = 'opacity: 1';
}

function dragEnterHandler(e) {
  e.target.style = 'border: 2px dashed gray; box-sizing: border-box; background: whitesmoke';
}

function dragOverHandler(e) {
  event.preventDefault();
}

function dragLeaveHandler(e) {
  e.target.style = 'border: none; background: #abcdef';
}

function dropHandler(e) {
  e.preventDefault();
  dragLeaveHandler(e); 

  const dataSourceId = e.dataTransfer.getData('text'); 
  const dataTargetId = e.target.getAttribute('data-target-id');

  if(dataSourceId === dataTargetId) {
    e.target.insertAdjacentHTML('afterbegin', dataSourceId);
    e.target.style = 'border: none; background: #abcdef';

    let sourceElemDataId = 'span[data-source-id="' + dataSourceId + '"]';
    let sourceElemSpanTag = document.querySelector(sourceElemDataId);

    Object.assign(sourceElemSpanTag, {
      className: 'no-longer-draggable',
      draggable: false,
    });
  }

}
Enter fullscreen mode Exit fullscreen mode

Final Thoughts

Even though our game is finished, this doesn’t have to be the end of the road!

It’s always possible to further improve our code. There are many additional things that can be done here.

We could, for example:

  • Add a start and end screen;
  • Add a counter that would count the number of attempts;
  • Add a countdown timer that would not limit the number of attempts, but rather the time available for us to complete our puzzle game;
  • Add more questions;
  • Add a leaderboard (we’d need to persist our data somehow);
  • Refactor the logic of our game so that we can keep the questions and the order of words in a simple JS object;
  • Fetch the questions from a remote API.

There are always more things to learn and more ways to expand our apps. However, all of the above-listed improvements are out of the scope of this tutorial⁠—learning the basics of the Drag and Drop API by building a simple game in vanilla JS.

The fun of coding is in the fact that you can try things on your own. So, try to implement some of these improvements, and make this game your own.

Lastly, if you want to secure your JavaScript source code against theft and reverse-engineering, you can try Jscrambler for free.


Originally published on the Jscrambler Blog by Ajdin Imsirovic.

Top comments (1)

Collapse
 
dongnhan profile image
dongnhan

Great introduction and explanation, thank you 🤓