A version of 2048 written in Eve.
This section sets up the records we will use to represent the game board and store the current state.
- The
#game
record will store the overall game state. #axis
records represent the horizontal and vertical axis#direction
records map key presses to movement along an axis#colour
records map tile values to the colours to render them in
commit
[#game, state: "init", seed: 0, score: 0]
[#axis, idx: 0, max: 3]
[#axis, idx: 1, max: 3]
[#direction, key: 37, axis: 0, dir: -1]
[#direction, key: 38, axis: 1, dir: -1]
[#direction, key: 39, axis: 0, dir: 1]
[#direction, key: 40, axis: 1, dir: 1]
[#colour, value: 2, fg: "#776e65", bg: "#eee4da"]
[#colour, value: 4, fg: "#776e65", bg: "#ede0c8"]
[#colour, value: 8, fg: "#f9f6f2", bg: "#f2b179"]
[#colour, value: 16, fg: "#f9f6f2", bg: "#f59563"]
[#colour, value: 32, fg: "#f9f6f2", bg: "#f67c5f"]
[#colour, value: 64, fg: "#f9f6f2", bg: "#f65e3b"]
[#colour, value: 128, fg: "#f9f6f2", bg: "#edcf72"]
[#colour, value: 256, fg: "#f9f6f2", bg: "#edcc61"]
[#colour, value: 512, fg: "#f9f6f2", bg: "#edc850"]
[#colour, value: 1024, fg: "#f9f6f2", bg: "#edc53f"]
[#colour, value: 2048, fg: "#f9f6f2", bg: "#edc22e"]
So we can refer to a particular row or column, we create a #line
record for each.
search
axis = [#axis, max]
idx = range[from: 0, to: max]
bind
[#line axis idx]
For each intersection of a horizontal and vertical line, we then create a #cell
record to represent each avaiable postion on the grid.
These cell records are constants; We will indicate if there is a number in a cell by creating a #tile
record which references the cell.
search
line0 = [#line axis: [#axis idx: 0]]
line1 = [#line axis: [#axis idx: 1]]
bind
cell = [#cell line: line0, line: line1]
After the user has made a move, we need check if further moves are possible by merging or moving tiles. We create the following records that will be updated with this information after every move:
[#can-merge, axis, after-move, value]
:value
will betrue
if after moveafter-move
completed a merge is possible on the specifiedaxis
[#can-move, axis, dir, after-move, value]
:value
will betrue
if after moveafter-move
completed a move is possible in the specified direction (dir
) on theaxis
.
search
not([#can-merge])
axis = [#axis]
commit
[#can-merge, axis, after-move: -1, value: false]
search
not([#can-move])
axis = [#axis]
direction = [#direction dir]
commit
[#can-move, axis, dir, after-move: -1, value: false]
This section is responsible for moving the game between states (recorded in state
in the [#game]
record).
The game can be in any one of the following states:
init
: Initialisingadding
: Adding new tilesdetect-moves
: Detecting possible moves and mergesdetect-completion
: Detecting whether the player has won or lostidle
: Awaiting a keypress from the usermoving
: Moving tiles in response to a keypresswon
: User has wonlost
: User has lost (no further moves possible)
User has request a new game, so reset the game state, and indicate we want to add 2 new tiles.
search @session @event @browser
[#click element: [#newgame]]
[#time, frames]
game = [#game]
tile = if tile = [#tile] then tile else false
can-move = [#can-move]
can-merge = [#can-merge]
commit
tile := none
can-move.after-move := -1
can-merge.after-move := -1
game <- [move: [idx: 0, axis: 0, dir: 0], seed: frames, add-count: 2, state: "adding", score: 0]
When we have finished adding new tiles, check what moves are available.
search
game = [#game, state: "adding", add-count = 0]
commit
game.state := "detect-moves"
After we have calculated the possible moves we are ready to determine if the game has ended.
search
game = [#game, state: "detect-moves", move]
count[given: [#can-move]] = count[given: [#can-move, after-move = move.idx]]
commit
game.state := "detect-completion"
The game is won if there is a 2048 tile present and is lost if the grid is full and there are no possible merges.
search
game = [#game, state: "detect-completion"]
state =
if count[given: [#tile, value: 2048]] > 0 then "won"
else if count[given: [#tile]] < count[given: [#cell]] then "idle"
else if count[given: [#can-merge, value = true]] > 0 then "idle"
else "lost"
commit
game.state := state
If the user pressed a direction key and there is a possible move for that direction, record the new move and begin moving
.
search @event
[#keyup, key]
search
game = [#game, state: "idle", move]
[#direction, key, axis: axis-idx, dir]
[#can-move, axis: [#axis, idx: axis-idx], dir, value = true]
commit
game <- [state: "moving", move: [idx: move.idx + 1, axis: axis-idx, dir]]
After a move has complete, add a new tile.
search
game = [#game, state: "moving", move]
moved-tiles = count[given: [#tile, last-move = move.idx]]
all-tiles = count[given: [#tile]]
moved-tiles = all-tiles
commit
game <- [state: "adding", add-count: 1]
This section contains the main game code that specifies what happens in each of the game states.
First we determine if a merge is possible on either axis. A merge is possible if there are two tiles on the same line with the same value and there is no tile inbetween them.
We use after-move
to indicate that we have calculated the #can-merge
for the current move.
search
game = [#game, state: "detect-moves", move]
axis = [#axis]
other-axis = [#axis] != axis
can-merge = [#can-merge, axis: other-axis, after-move < move.idx]
value =
if cell = [#cell, line, line: [#line, axis: other-axis, idx]]
tile = [#tile, cell: cell, value]
[#tile, cell: [#cell, line, line: [#line, axis: other-axis, idx: idx1]], value]
idx1 > idx
not(
[#tile, cell: [#cell, line, line: [#line, axis: other-axis, idx: idx2]]]
idx < idx2 < idx1
)
then true
else false
commit
can-merge <- [value, after-move: move.idx]
Once we have calculated #can-merge
for an axis, we can determine if there are any possible moves on the axis in either direction.
A move is possible for a particular direction if there is a merge available or there is an empty cell on the same line as a tile in the direction of movement.
search
game = [#game, state: "detect-moves", move]
[#direction, axis: axis-idx, dir]
axis = [#axis, idx: axis-idx]
other-axis = [#axis] != axis
[#can-merge, axis: other-axis, after-move = move.idx]
can-move = [#can-move, axis: other-axis, dir, after-move < move.idx]
value =
if [#can-merge, axis: other-axis, after-move: move.idx, value = true] then true
else if
cell = [#cell, line, line: [#line, axis: other-axis, idx]]
tile = [#tile, cell: cell]
next-cell = [#cell, line, line: [#line, axis: other-axis, idx: idx + dir]]
not([#tile, cell: next-cell])
then true
else false
commit
can-move <- [value, after-move: move.idx]
Add a new tile at a random free cell and decrease add-count
. Adding will continue until add-count
is zero.
Since values in Eve are unordered sets it is little tricky to work out how to select a random cell from the list of free cells. To do this we use the sort
function to associate an index with each of the avaiable cells. We can then constrain the index to be equal to a random number and use the associated cell as our tile location.
search
game = [#game, state: "adding", move, seed, add-count]
add-count > 0
cell = [#cell]
not([#tile, cell])
num-free-cells = count[given: cell]
sort[value: cell] = idx
base-seed = seed + move.idx + add-count
idx = 1 + round[value: random[seed: base-seed] * (num-free-cells - 1)]
value = if random[seed: base-seed * 13] < 0.9 then 2 else 4
new-count = add-count - 1
commit
[#tile, cell, value, last-move: move.idx, last-merge: -1]
game.add-count := new-count
When a move has been requested, game.move
specifies the direction of the requested move.
The rules are:
- Move the closest tiles to the edge in the direction of movement first
- If a tile hits a tile with the same value, and that tile hasn't already been merged this turn, merge them
- If a tile hits a tile with a different value, move it adjacent to it
- On a merge, remove the hit tile, double the value on the moved tile and add this to the game score
search
game = [#game, state: "moving", move]
move-axis = [#axis, idx: move.axis, max: max-pos]
other-axis = [#axis] != move-axis
move-tile = [#tile, cell, last-move < move.idx]
cell = [#cell, line, line: other-line]
line = [#line, axis: move-axis, idx: cur-pos] // the tile will move to a different line on this axis
other-line = [#line, axis: other-axis] // the tile will move along this line (i.e. remain on it)
// Don't move a tile if there are tiles closer to the edge that haven't been moved yet
not(move.dir < 0,
cell-in-front = [#cell, line: [#line, axis: move-axis, idx < cur-pos], line: other-line],
[#tile, cell: cell-in-front, last-move < move.idx]
)
not(move.dir > 0,
cell-in-front = [#cell, line: [#line, axis: move-axis, idx > cur-pos], line: other-line],
[#tile, cell: cell-in-front, last-move < move.idx]
)
// Find the position of the tile we will hit (if any) if we move in the requested direction
hit-pos =
if move.dir < 0,
block-tile = [#tile, cell: [#cell, line: [#line, axis: move-axis, idx: tile-pos], line: other-line]]
tile-pos < cur-pos
then max[given: block-tile, value: tile-pos]
else if move.dir > 0,
block-tile = [#tile, cell: [#cell, line: [#line, axis: move-axis, idx: tile-pos], line: other-line]]
tile-pos > cur-pos
then min[given: block-tile, value: tile-pos]
else false
// Depending on hit-pos, determine whether the tile will move to hit-pos, merge with another tile, or move
// adjacent to a tile already at that position
(new-cell, new-value, merge-tile, last-merge, score-inc) =
if hit-pos = false,
move.dir > 0
then ([#cell, line: [#line, axis: move-axis, idx: max-pos], line: other-line], move-tile.value, false, -1, 0)
else if hit-pos = false
then ([#cell, line: [#line, axis: move-axis, idx: 0], line: other-line], move-tile.value, false, -1, 0)
else if hit-tile = [#tile, cell: [#cell, line: [#line, axis: move-axis, idx: hit-pos], line: other-line], value: hit-value, last-merge]
hit-value = move-tile.value
last-merge != move.idx
then ([#cell, line: [#line, axis: move-axis, idx: hit-pos], line: other-line], move-tile.value * 2, hit-tile, move.idx, hit-value * 2)
else ([#cell, line: [#line, axis: move-axis, idx: hit-pos - move.dir], line: other-line], move-tile.value, false, -1, 0)
// Update the game score
new-score = game.score + sum[given: new-cell, value: score-inc]
commit
move-tile <- [cell: new-cell, value: new-value, last-move: move.idx, last-merge]
merge-tile := none
game.score := new-score
This section renders the game on the browser.
Add score caption, new game button and a parent SVG element to contain the game grid.
search
width = 250
height = 250
[#game, score, state]
[#axis, idx: 0, max: max0]
[#axis, idx: 1, max: max1]
status = if state = "won" then "WON"
else if state = "lost" then "LOST"
else ""
bind @browser
[#p, sort: 1, text: "Score {{score}} {{status}}"]
[#svg, sort: 2, viewBox: "0 0 {{max0 + 1.25}} {{max1 + 1.25}}", width, height, children:
[#rect, rx: 0.1, ry: 0.1, x:0, y:0, width: max0 + 1.25, height: max1 + 1.25, fill: "#bbada0"]
[#g, #grid, transform: "translate(0.125, 0.125)"]
]
[#p, sort: 3, children:
[#button, #newgame, style: [width: "10em"], text: "New Game"]
]
We set up a viewBox
on the parent SVG element to modify the coordinate system so that we can just position cells with our line indexes.
We also apply a transform when rendering each cell so that it is scaled down slightly to provide a border and the origin is the centre of the cell.
search @browser
grid = [#grid]
search
axis0 = [#axis, idx: 0]
axis1 = [#axis, idx: 1]
cell = [#cell, line: [axis: axis0, idx: idx0], line: [axis: axis1, idx: idx1]]
(x, y, fill, text-col, text) =
if [#tile, cell, value] [#colour, value, bg, fg] then (idx0, idx1, bg, fg, value)
else (idx0, idx1, "#cdc1b4", "", "")
bind @browser
grid.children +=
[#g, transform: "translate({{x + 0.5}},{{y + 0.5}}) scale(0.85)", children:
[#rect, rx: 0.05, ry: 0.05, x:-0.5, y:-0.5, width: 1, height: 1, fill]
[#text, x:0, y:0, font-size: 0.4, font-weight: "bold", fill: text-col, dominant-baseline: "middle", text-anchor: "middle", text]
]