Elm Drag and Drop Game — Towers of Hanoi

A tutorial with HTML5 drag and drop

Photo Credit: Hanoi by Night | by pimnl | WikiMedia Commons

This tutorial is a follow-up to an earlier post on HTML5 Drag and Drop in Elm.

The starting point for this tutorial is an unfinished version of the game Towers of Hanoi. The rules of this game are:

The game, old school
  • There are 3 poles
  • The left poles has a number of disks on it, varying in size, which the smallest size on top.
  • You can move disk from one pole to another pole, but only if there is no disk on the other pole, or if the top disk on the other pole is larger than the one you are moving.

Getting started

All documents for this tutorial can be found over on github. To get started:

  • Clone or download the elm-html5-drag-drop repository over on github.
  • Open the folder labeledexamples.
  • The file tutorial-starter.elm is the starting point.

If you just want to look at the finished example, check out tutorial-finished.elm in the same folder. You can read the code and run the example using elm-reactor.

The starting point for now is an unfinished version of the game Towers of Hanoi. Specifics of the game logic are extracted into their own module Hanoi.elm.

The setup of main tutorial-starter.elm app is as follows:

Model

type alias Model =
{ poles : Hanoi.Poles -- alias for List (List Int)
, movingDisk : Maybe Hanoi.Disk
}

The model holds a list of (3) poles, and each pole has a list of up to 7 disks (sorted from small to large). Each disk is an Int, to indicate the size of the disk. In addition, the model holds an indicator if and which disk is currently being moved.

Msg and update

The update function to start with simply returns an unchanged model. There is 1 message placeholder NoOp, that doesn’t do anything and is never invoked. This will be filled in later.

View

The mainview function calls a helper functionviewPole for each pole in the model. In turn, viewPole calls viewDisk for each disk (if any) on the pole.

If you run the tutorial-starter, you should see this:

The (rather boring) starting point of the Towers of Hanoi game

It’s the starting setup of the game. Right now, the user cannot do anything yet. You will fix that with drag and drop 😉.

Step 1. Make an item draggable

Making an item draggable happens in the view function. If a disk is on top of the set on a pole and if there are other poles where the disk can move to, then the user should be able to drag them.

The code already identifies for each disk if it is on top of a pole and has valid destinations. You only need to add the attributes and events to make these disks draggable.

You will need to modify the lines below, which are in the viewDisk function.

snippet in viewDisk where events and attributes are set

idx is the index of the disk on the pole. If it is0 then this is the top disk (the only potentially moveable disk on any pole). Hanoi.canMove is a helper function that determines if any other pole can actually take the current disk (if both other poles only have smaller disks on top, it will return False).

Change the empty attribute list to this:

[ attribute "draggable" "true" ]

This sets the attribute (imported from Html.Attributes) draggable to true. Now, whenever a disk is draggable (and green), your code has also made it a draggable item.

You can now run your code, to see that you can drag the green disk (a ghost image of the disk will appear).

Step 2. Keep track of drags

The starting model already has a flag to keep track of moving disks. To start using it, change your Msg type, so the model can be informed that the user started dragging a disk:

type Msg
= Move Hanoi.Disk

Hanoi.Disk is an imported type alias for Int, the ID of the disk being moved (also used to determine disk size, so lower ID == smaller disk).

Then, add a (custom) event to your view function to fire whenever the user starts dragging, right below the draggable attribute you just set:

[ attribute “draggable” “true”
, onDragStart <| Move disk
]

disk is the ID of the current disk being rendered in viewDisk. onDragStart is an imported custom event handler, invoked when the dragstart event fires.

When you define your own custom handler for the onDragStart event, it could look like this (using the on function from the Html.Events package):

The custom handlers used in this tutorial are defined in a separate module DragEvents.elm. You can look at that file to see how they are defined.

In Firefox, HTML5 drag and drop will only work if you directly set the data of whatever is being dragged. If you are using another browser, you can skip this part.


Firefox fix
To make HTML5 drag and drop work in Firefox, you need to call event.dataTransfer.setData(), a method on the dataTransfer object, which is part of the drag event. The clean way to do this Elm is by a port, but there is also a “hack” that you can use: add a one-line javascript statement in an additional ondragstart listener.
[ attribute “draggable” “true”
, onDragStart <| Move disk
, attribute "ondragstart"
"event.dataTransfer.setData(\"text/plain\", \"dummy\")"
]
This ensures that Firefox will also fire all subsequent drag-and-drop events.

Now that you have ensured that a message is triggered when the user starts dragging, you need to change the update function, to handle the message we defined.

update : Msg -> Model -> Model
update msg model =
case msg of
Move selectedDisk ->
{ model | movingDisk = Just selectedDisk }

Since this is a move, instead of a copy action, it would be better if the original disk no longer shows on the pole once the user starts dragging.

You can do this in this bit of code inside your viewDisk function.

These are the (currently empty) styles and attributes for the disk if it is the moving disk. Change the empty lists inside the tuple to:

( [ ("opacity","0.1") ]
, [ attribute "draggable" "true" ]
)

(Your code will still work without setting the draggable attribute for the disk in moving state. But for consistency, it is probably better to put it in anyway.)

Now if you run your code again, you should see that once you start dragging the green disk, the original disk on the pole will be almost transparent.

And, now that you have wired up to change the .isMoving flag in the model, you may notice that the 2 poles on the right will change color to green (to indicate that this is were you can move the disk to).

Once you drop the green disk anywhere, the game state doesn’t change: the disk on the pole remains transparent, and the poles on the right remain green. You’ll fix that next..

Step 3. Handle cancelations of drag

If the user lets go of the dragged item at a random location, the model needs to switch back to the nothing-is-being-dragged mode.

You can do this by setting up a onDragEnd event listener on the item being dragged. For this, you add a message type and a branch to your update:

type Msg
= Move Hanoi.Disk
| CancelMove
update msg model =
case msg of
...
CancelMove ->
{ model | movingDisk = Nothing }

And in your diskView function, add the (custom) handler to send the message, in the place where you set the transparency:

( [ ("opacity","0.1") ]
, [ attribute "draggable" "true"
, onDragEnd CancelMove
]
)
Now run your code again. This time, when you let go of the green disks, the poles and all disks will go back to their non-moving state..

Bonus: onDragEnd only fires if the drag ends somewhere outside an official drop zone. It does not fire if the user drops on a proper drop zone.

But at this point, every drop, even on a green pole, will simply cancel. The user still has no valid drop zones. That’s the final step to complete the game.

Step 4. Enable drop-zones

The app already identifies if a pole is ready to receive a disk being dragged. These poles are colored green when a disk is dragged. That is already wired up in the viewPole function. The final step is to set up the poles as proper drop zones.

First, add another message to handle the drop:

type Msg
= Move Hanoi.Disk
| CancelMove
| DropOn Hanoi.Pole

Hanoi.Pole is a type alias for Int, serving as ID for the pole where the disk is dropped.

Next, setup a drop handler to the pole. In this bit of code in the viewPole function:

Add this in the empty list:

[ attribute "ondragover" "return false"
, onDrop <| DropOn pole
]

The "ondragover" attribute is a bit of a hack. It is telling the browser to prevent the default drag behavior (drop is not allowed) on this element. Instead of setting a property, you provide a single line javascript statement to "return false".

onDrop is a custom drop handler. This handler needs to prevent default behavior as well, and therefore should be set up with onWithOptions from the Html.Events package. E.g. like this:

Now that your code can fire the drop message, including the pole to drop on, you finish this step by adding the branch for it in your update function:

update msg model =
case msg of
...

DropOn newPole ->
let
newPoles =
model.movingDisk
|> Maybe.map
(\movingDisk ->
Hanoi.moveDisk
movingDisk
newPole
model.poles
)
|> Maybe.withDefault
model.poles
in
{ model
| poles = newPoles
, movingDisk = Nothing
}

In this bit of code, if the model has a moving disk, you call the helper function Hanoi.moveDisk, which moves the movingDisk from wherever it happened to be, to the newPole (but only if this is a valid move). The result is stored in newPoles. Your code then updates the model with the newPoles, and also resets the moving state of the model.

Congratulations!
You are done! Your app is finished, and you can now play a whole game.

Hope you enjoyed the tutorial, and learned a bit of Html5 drag and drop along the way!

Further exercises

HTML5 has other drag and drop events as well. Custom elm versions are available in the DragEvents.elm file. If you want more exercise, here are some ideas for further exploration:

  • Make the receiving poles change color whenever the dragged disk hovers over it, using the onDragEnter and onDragLeave events.
  • Prevent the user from dragging a disk beyond the boundaries of the app’s screen, by using the onDragLeave event.
  • Add a counter to count the number of moves. With 7 disks, the minimum amount of moves required to move all disks from left pole to right pole is 127 (²⁷ -1)…
Show your support

Clapping shows how much you appreciated Wouter In t Velt’s story.