This is day 5 of my 30 day Elm challenge
Today's project is the Lights Out game!
Here's the end result: https://ellie-app.com/bRXymtmp5t7a1
Initial plan:
- Model/view: 5x5 list of boolean values, rendered as checkboxes.
- When clicking a checkbox, its left/right/up/down neighbors also get toggled.
- The goal is to uncheck all the checkboxes.
This is going to be a long post, and I'm tired right now, so bear with me. :)
Svelte prototype
Here's the prototype I made in Svelte, which served as a very good starting point.
The Svelte prototype is nearly half the size of my final Elm project, which has less styling. Maybe Elm's safety would lead to less error checking in a more complex project?
I decided to represent the board as a one-dimensional array, just to keep my head straight later on in Elm.
<script>
let board = [...Array(25)].map((value, index) => {
const x = index % 5;
const y = Math.floor(index / 5);
return {
x,
y,
checked: Math.random() < 0.5,
};
});
function toggleNeighbors(x, y) {
board = board.map((checkbox) => {
const xDistance = Math.abs(checkbox.x - x);
const yDistance = Math.abs(checkbox.y - y);
const isNeighbor =
(xDistance === 1 && yDistance === 0) ||
(xDistance === 0 && yDistance === 1);
if (isNeighbor) {
return {
...checkbox, // Return everything from the checkbox object
checked: !checkbox.checked, // Overwrite the checked property
};
} else {
return checkbox; // If we don't return anything here, we get undefined -_-
}
});
}
function easyMode() {
board = board.map((checkbox) => {
return {
...checkbox,
checked:
(checkbox.x === 1 && checkbox.y === 2) ||
(checkbox.x === 2 && checkbox.y === 1) ||
(checkbox.x === 2 && checkbox.y === 2) ||
(checkbox.x === 2 && checkbox.y === 3) ||
(checkbox.x === 3 && checkbox.y === 2),
};
});
}
</script>
<style>
button {
display: block;
}
input {
margin: 0;
transform: scale(2);
}
label {
background-color: #fff;
border: 1px solid #999;
border-right: none;
display: inline-block;
margin: -2px;
padding: 1.5rem;
transition: 0.1s ease-in;
}
label:hover {
background-color: #eee;
}
/* The hr also counts as a child element.
Therefore we select every 6th element + 1 */
label:nth-child(6n + 1) {
border-right: 1px solid #999;
}
hr {
margin: 0;
padding: 0;
visibility: hidden;
}
</style>
<button on:click={easyMode}>Easy mode</button>
<div id="board" />
{#each board as input, i}
<label>
<input
type="checkbox"
bind:checked={input.checked}
on:change={() => {
const x = i % 5;
const y = Math.floor(i / 5);
toggleNeighbors(x, y);
}} />
</label>
{#if i % 5 === 4}
<hr />
{/if}
{/each}
{#if board.every((checkbox) => !checkbox.checked)}
<p>You win!</p>
{:else}
<p>{board.filter((checkbox) => checkbox.checked).length}/25</p>
{/if}
1. Main.elm walkthrough
This was a challenging project with a couple of headaches, mostly related to using the wrong event listener.
I was also unsure of what I should include in my data structure, but using records from the beginning allowed for flexibility.
2. Import / main
module Main exposing (init, main, update, view)
import Browser
import Html exposing (Html, button, div, hr, input, label, p, text)
import Html.Attributes exposing (checked, style, type_)
import Html.Events exposing (onClick)
main : Program () Board Msg
main =
Browser.sandbox
{ init = init
, update = update
, view = view
}
Nothing here that hasn't been used in my previous projects.
3. Model
type alias Checkbox =
{ x : Int, y : Int, checked : Bool }
type alias Board =
{ checkboxes : List Checkbox }
init : Board
init =
{ checkboxes =
List.indexedMap
(\index _ ->
{ x = remainderBy 5 index
, y = index // 5
, checked = remainderBy 2 index == 0
}
)
(List.repeat 25 { x = 0, y = 0, checked = True })
I was considering having other fields than checkboxes, but decided against it for simplicity's sake.
The separate Checkbox
type alias came in handy in two of the update-related functions.
Surprisingly, Elm doesn't have the %
operator! To check for an even number in JavaScript, you type n % 2 === 0
, while in Elm, you can choose between remainderBy
or modBy
(see documentation).
I chose remainderBy
because that's closest to what I would say in conversation with someone.
List.indexedMap
takes an index and a value, just like JavaScript's Array.map
. Things are kind of backwards, which can trip you up at first:
JavaScript:
myStuff.map((value, index) => /* Return something */ )
Elm:
List.indexedMap (\index value -> {- Return something -}) myStuff
The last line with List.repeat
doesn't feel right to me. It does the job, but only one of the checkboxes will have an XY position of (0, 0).
I'm pretty sure I'll look back on this later, and see a more elegant way of doing it.
4. Update
Let's begin with just the Msg type, which provides a nice overview of possible user actions. The PressButton
message comes with the XY position of the clicked checkbox.
I just used a tuple because I saw it in the documentation. Is there a practical difference between tuples and a list of length 2?
ActivateEasyMode
does what it says.
type Msg
= PressButton ( Int, Int )
| ActivateEasyMode
Now this is where things get crazy. elm-format
and I have made beautiful stairs of code together! :D
I did fix it! But first, the original code in all its glory:
update : Msg -> Board -> Board
update msg model =
case msg of
PressButton xyTuple ->
toggleNeighborsAndSelf (Tuple.first xyTuple) (Tuple.second xyTuple) model
ActivateEasyMode ->
{ model
| checkboxes =
List.map
(\checkbox ->
{ checkbox
| checked =
(checkbox.x == 1 && checkbox.y == 2)
|| (checkbox.x == 2 && checkbox.y == 1)
|| (checkbox.x == 2 && checkbox.y == 2)
|| (checkbox.x == 2 && checkbox.y == 3)
|| (checkbox.x == 3 && checkbox.y == 2)
}
)
model.checkboxes
}
It's fun to look at, but ultimately I prefer anonymous functions to not go beyond one line. Separate named functions are a lot nicer to read:
checkIfEasy : Checkbox -> Checkbox
checkIfEasy checkbox =
{ checkbox
| checked =
(checkbox.x == 1 && checkbox.y == 2)
|| (checkbox.x == 2 && checkbox.y == 1)
|| (checkbox.x == 2 && checkbox.y == 2)
|| (checkbox.x == 2 && checkbox.y == 3)
|| (checkbox.x == 3 && checkbox.y == 2)
}
update : Msg -> Board -> Board
update msg model =
case msg of
PressButton xyTuple ->
makeAMove (Tuple.first xyTuple) (Tuple.second xyTuple) model
ActivateEasyMode ->
{ model | checkboxes = List.map checkIfEasy model.checkboxes }
4.1 Toggling self and neighbor checkboxes
This is where the magic happens. One click gets turned into several:
toggleSelfAndNeighbors : Int -> Int -> Checkbox -> Checkbox
toggleSelfAndNeighbors x y checkbox =
let
xDistance =
abs (checkbox.x - x)
yDistance =
abs (checkbox.y - y)
isNeighborOrEventTarget =
(xDistance == 0 && yDistance == 0)
|| (xDistance == 1 && yDistance == 0)
|| (xDistance == 0 && yDistance == 1)
in
if isNeighborOrEventTarget then
{ checkbox | checked = not checkbox.checked }
else
checkbox
makeAMove : Int -> Int -> Board -> Board
makeAMove x y model =
{ model | checkboxes = List.map (toggleSelfAndNeighbors x y) model.checkboxes }
I hadn't tried the let .. in
construct before. I guess let
declarations don't return anything, while the in
part does.
The essence is this function is basically this:
- Loop through all the checkboxes
- If the checkbox's XY position matches the condition:
- Set it equal to the opposite of what it was.
- Return the new model.
5. View
The main view function contains quite a few things, but has been simplified by extracting things into their own functions.
Readability is a very important factor to consider. The code has to be read by someone else (a colleague, open source collaborator, or myself in a couple of months)
Just imagine how messy the generate5x5CheckboxBoard
function would be inline in the view function! :o
In this case, I think the last p tag's contents would benefit from extraction to a function with a descriptive name, rather than a let .. in if .. else
statement.
5.1 Main view function
numberOfCheckedItems : Board -> Int
numberOfCheckedItems model =
List.length (List.filter (\cb -> cb.checked) model.checkboxes)
view : Board -> Html Msg
view model =
div [ style "padding" "1rem" ]
[ button
[ onClick ActivateEasyMode
, style "padding" "1rem"
, style "margin-bottom" "1rem"
]
[ text "Easy mode" ]
, div [] <| generate5x5CheckboxBoard model
, p []
[ text
(let
checkedCount =
numberOfCheckedItems model
in
if checkedCount == 0 then
"You win!"
else
String.fromInt checkedCount ++ "/25"
)
]
]
To be honest, I just saw someone else do the <| thing, but it works. Just put this stuff where the arrow points.
5.2 Generate 5x5 checkbox board
To make the 5x5 function more readable, I made two functions: htmlIf
and row
.
I found htmlIf
here: https://discourse.elm-lang.org/t/are-there-any-common-patters-for-dealing-with-conditionally-including-markup/5242
htmlIf : Bool -> Html msg -> Html msg
htmlIf condition element =
if condition then
element
else
text ""
row : Html msg
row =
hr [ style "visibility" "hidden", style "margin" "0.5rem 0" ] []
generate5x5CheckboxBoard : Board -> List (Html Msg)
generate5x5CheckboxBoard model =
List.indexedMap
(\index checkbox ->
label
[ style "border" "1px solid #999"
, style "border-right" "none"
, style "margin" ".5rem"
]
[ input
[ type_ "checkbox"
, onClick (PressButton ( checkbox.x, checkbox.y ))
, checked checkbox.checked
, style "transform" "scale(2)"
]
[]
, htmlIf (remainderBy 5 index == 4) row
]
)
model.checkboxes
The biggest challenge I faced today was the event listener. To begin with, I was using an onCheck
event listener, which I got working on its own, but then I wanted to pass the XY values along with the message.
onCheck
kept insisting on 1 argument, but I also wanted to pass it my XY values.
I read all kinds of crazy stuff about custom events, combined with subscriptions and JSON decoding, and almost had it working.
Then I found another example with 0 votes on Stack Overflow, using the previous version of Elm, and an onClick event listener: https://stackoverflow.com/questions/46019061/updating-model-with-parameter-in-elm-0-18
I converted it to 0.19, and realized that onClick was what I was looking for all along! https://ellie-app.com/bRXkJSnt6G9a1
Maybe there's a way I could have achieved what I wanted to anyway. I'll make sure to read the documentation after a good night's sleep.
https://package.elm-lang.org/packages/elm/html/latest/Html-Events
Conclusion
- Separate functions can make code a lot more readable.
- Svelte is a great prototyping tool.
- Working with events and data will require some practive.
- Lights Out is a fun game!
See you tomorrow for a simpler project. :)
Top comments (1)
I completely agree, especially since you're not using the value when you use
List.indexedMap
. I'd probably just useList.map (\index -> ...) List.range 0 24
for building the initialstate.There's a huge difference! The best example probably being that with the tuple you can do
but with a
List Int
you'd have to do something likeFor both your Svelte and Elm code, you might be interested in Manhattan distance. Using this would allow you to just check the distance between 2 points being equal to 1 and not have to check the x and y distances separately.
There totally is a way! You could change your
Msg
fromPressButton ( Int, Int )
toPressButton ( Int, Int ) Bool
and then in your view doYou'll see that I changed from
onClick
toonCheck
which is expecting a function that takes aBool
and returns aMsg
. By doing onlyPressButton ( checkbox.x, checkbox.y )
I've created a function that is expecting aBool
and returns aMsg
. But your solution also works great.Can't wait to read more!