A cute little puzzle game, where you move around snake(birds). Move any number of snakes around the level, eating fruit, and getting to the exit. The main gotchas are that you have gravity to content with–your snake will easily fall off the edge of the world–and each time you eat a fruit, your snake gets bigger. This can help get longer to get into hard to reach places or it can cause trouble when you trap yourself in corners.
Let’s use the new immutable.js solver to solve these problems!
First, a representation of state. The above level can be represented in a text format as so:
----------------------
--------##-----*------
--------####----------
----#----##-----------
------#--#---+-#------
---CBA---#-#---##---+-
-#######---##-------##
######################
The snake is defined as ABC
(you can currently have up to three, A-Z
, a-z
, and 0-9
). Walls are #
, fruit is +
, and the exit is *
. Later levels have spikes that kill you on contact, which I’ll represent with ^
.
To load that in, we scan through the lines/columns of that input, building up an immutable.js Record
with a Set
of Point
(another Record
) for each type of object. The snakes are List
of Point
(although once they leave, I will null
them out).
import { List, Set, Record } from 'immutable'
const SNAKE_BOUNDS = [
{min: 'A', max: 'Z'},
{min: 'a', max: 'z'},
{min: '0', max: '9'},
]
let Point = Record({r: 0, c: 0})
let State = Record({
walls: Set(), // tiles that can't be walked through
spikes: Set(), // tiles that kill snakes touching them
snakes: List(), // each snake is an ordered list of points from head to tail
fruits: Set(), // each fruit makes the snake that eats it one longer
exit: Point(), // each snake that reaches the exit is removed, when the last is removed you win
})
function loadLevel(path) {
let rawData = fs.readFileSync(path, {encoding: 'utf8'})
let data = rawData.split('\n')
let walls = Set()
let spikes = Set()
let snakes = List()
let fruits = Set()
let exit = Point()
// a list of snake definitions in files with an ordered range of characters for each
// example snakes would be ABC or abc or 123
let rawSnakes = SNAKE_BOUNDS.map(_ => [])
for (var r = 0; r < data.length; r++) {
cloop:
for (var c = 0; c < data[r].length; c++) {
let pt = Point({r, c})
// empty space
if (data[r][c] == '-') {
continue
}
// walls
if (data[r][c] == '#') {
walls = walls.add(pt)
continue
}
// spikes
if (data[r][c] == '^') {
spikes = spikes.add(pt)
continue
}
// fruits
if (data[r][c] == '+') {
fruits = fruits.add(pt)
continue
}
// exit
if (data[r][c] == '*') {
exit = pt
continue
}
// snakes, just collect the points for now with the character for later sorting
for (let i = 0; i < SNAKE_BOUNDS.length; i++) {
if (data[r][c] >= SNAKE_BOUNDS[i].min && data[r][c] <= SNAKE_BOUNDS[i].max) {
rawSnakes[i].push([data[r][c], pt])
continue cloop
}
}
// wtf
throw `Unknown character ${data[r][c]} at ${r}:${c}`
}
}
// sort the snakes based on their character definition, push the list of points to state
for (let rawSnake of rawSnakes) {
if (rawSnake.length == 0) continue
let pts = []
for (let [_, pt] of rawSnake.sort()) {
pts.push(pt)
}
snakes = snakes.push(List(pts))
}
return State({walls, spikes, snakes, fruits, exit})
}
Next, let’s reverse that for debugging purposes:
function snakebirdToString(state) {
// Determine drawing bounds
// This is necessary since snakes can run off platforms a bit
let [minR, maxR, minC, maxC] = [0, 0, 0, 0]
function bound(pt) {
minR = Math.min(minR, pt.r)
maxR = Math.max(maxR, pt.r)
minC = Math.min(minC, pt.c)
maxC = Math.max(maxC, pt.c)
}
state.walls.forEach(bound)
state.snakes.forEach(snake => snake != null && snake.forEach(bound))
state.fruits.forEach(bound)
bound(state.exit)
let str = ''
for (let r = minR; r <= maxR; r++) {
for (let c = minC; c <= maxC; c++) {
let pt = Point({r, c})
if (state.walls.has(pt)) {
str += '#'
} else if (state.spikes.has(pt)) {
str += '^'
} else if (state.fruits.has(pt)) {
str += '+'
} else if (state.exit.equals(pt)) {
str += '*'
} else if (state.snakes.some(snake => snake != null && snake.includes(pt))) {
for (let i = 0; i < state.snakes.size; i++) {
if (state.snakes.get(i) == null) continue
for (let j = 0; j < state.snakes.get(i).size; j++) {
if (state.snakes.get(i).get(j).equals(pt)) {
str += String.fromCharCode(SNAKE_BOUNDS[i].min.charCodeAt(0) + j)
}
}
}
} else {
str += '-'
}
}
str += '\n'
}
return str
}
It will get confused if more than one type of object is in the same place, but that shouldn’t be possible, so we’ll ignore the possibility for now.
Next, the isValid
and isSolved
functions, these are the easier part:
let isValid = function (state) {
// check if any snake is on a spike
if (state.snakes.some(snake => snake != null && snake.some(pt => state.spikes.has(pt)))) return false
// check if any snake is falling forever
// find the lowest row
let maxR = 0
state.walls.forEach(pt => { maxR = Math.max(maxR, pt.r) })
// check that each snake has at least one point within that bound
return state.snakes.every(snake => snake == null || snake.some(pt => pt.r <= maxR))
}
let isSolved = function (state) {
return state.fruits.size == 0 && state.snakes.every(snake => snake == null)
}
We’re going to rely on generateNextStates
to not generate a few classes of invalid states: you can’t move into a wall or another snake. isValid
then only needs to check if we’re on a spike (most likely we fell onto one, it won’t move onto one). But as an edge case, we need to make sure that a snake doesn’t run off the edge of the map and fall forever. I did design the state so that snakes can go slightly off the edge and come back in (it’s necessary for some levels), but once you start falling off the world, you’re going to fall forever.
Since generateNextStates
will null
snakes that reach the exit, isSolved
only needs to check that they’re all null
. Otherwise keep going.
Okay, enough of that. Let’s do the hard part: generateNextStates
:
let generateNextStates = function* (state) {
// handle hitting the exits (including by falling)
// can only exit if there are no fruits left
if (state.fruits.size == 0) {
for (let i = 0; i < state.snakes.size; i++) {
let snake = state.snakes.get(i)
if (snake === null) continue
// if we hit an exit, remove this snake
// only remove one snake per iteration
if (snake.some(pt => pt.equals(state.exit))) {
yield {
state: state.set('snakes', state.get('snakes').set(i, null)),
step: `${i}*`,
}
return
}
}
}
// handle gravity
gravityEachSnake:
for (let i = 0; i < state.snakes.size; i++) {
let snake = state.snakes.get(i)
if (snake == null) continue
for (let pt of snake) {
let ptd = pt.set('r', pt.get('r') + 1)
// supported by a wall
if (state.walls.has(ptd))
continue gravityEachSnake
// supported by another snake
if (state.snakes.some(otherSnake => snake != null && otherSnake != null && snake != otherSnake && otherSnake.includes(ptd)))
continue gravityEachSnake
}
// if we made it this far, the snake should fall one point
let newSnake = snake.map(pt => pt.set('r', pt.get('r') + 1))
yield {
state: state.set('snakes', state.get('snakes').set(i, newSnake)),
step: `${i}f`,
}
// Only generate a single fall per iteration
return
}
// otherwise try to move each snake in each direction
for (let i = 0; i < state.snakes.size; i++) {
let snake = state.snakes.get(i)
if (snake == null) continue
for (let [d, cd, rd] of [['→', 1, 0], ['←', -1, 0], ['↓', 0, 1], ['↑', 0, -1]]) {
// check that the square is empty
// hitting a fruit or an exit is fine
let pt = snake.get(0)
pt = pt.set('r', pt.get('r') + rd).set('c', pt.get('c') + cd)
if (state.walls.has(pt)) continue
if (state.snakes.some(snake => snake != null && snake.includes(pt))) continue
// move the snake by adding the new point to the front of the snake
// if we hit a fruit, keep the last element to make the snake longer
// otherwise, remove the end of the list to simulate movement
let newSnake = snake.unshift(pt)
let ateFruit = state.fruits.has(pt)
if (!ateFruit) {
newSnake = newSnake.pop()
}
let newState = state.set('snakes', state.snakes.set(i, newSnake))
if (ateFruit) {
newState = newState.set('fruits', newState.get('fruits').remove(pt))
}
yield {
state: newState,
step: `${i}${d}${ateFruit ? 'g' : ''}`,
}
}
}
}
There are three cases to deal with:
- Exiting the level
- Falling
- Moving (with a substate of eating a fruit)
First, exiting. To do that, we have to make sure that all of the fruits are gone first. Then we just check if any snake ran into the exit. If so, null it out. We only ever check if one snake exited at a time and don’t processing falling or moving if they do (with the early return
). So in this case, we can only yield a maximum of one move
.
Next, falling. This one is a bit tricky, since we have to deal with a few different rules:
- If any segment of the snake is resting on a wall or another snake, it doesn’t fall
- If the snake is resting on itself, it can fall (as long as the first point is met)
Ergo this fun condition:
if (state.snakes.some(otherSnake => snake != null && otherSnake != null && snake != otherSnake && otherSnake.includes(ptd)))
continue gravityEachSnake
If some
otherSnake
that is not null
(already exited), not the same as the current snake
, and includes any point of a wall, don’t fall. Otherwise, generate an updated snake where the each row is one greater.
The final condition is moving. Take each snake and each direction and potentially generate a move:
- If the square is occupied (by a wall, snake, or spike), don’t generate that move. Otherwise, check for a fruit. If there isn’t one, remove the last element of the snake and add a new head (to simulate moving). If there is, just generate a head and don’t remove the tail to grow.
And that’s it, we have all of the possible moves.
let solveSnakebird = makeSolver({ returnFirst: false, searchMode: 'bfs', debug: DEBUG, generateNextStates, isValid, isSolved })
Some additional wrapping code to run it on the command line here (snakebird.js) and we can run it:
$ node snakebird.js snakebird/0.txt
===== snakebird/0.txt =====
----------------------
--------##-----*------
--------####----------
----#----##-----------
------#--#---+-#------
---CBA---#-#---##---+-
-#######---##-------##
######################
time taken: 0.414
iterations: 11117
raw steps: start 0→ 0→ 0→ 0↓ 0→ 0→ 0↑ 0↑ 0→ 0→ 0→g 0→ 0↓ 0f 0→ 0→ 0→ 0→ 0→ 0↑ 0→g 0↑ 0← 0← 0← 0← 0↑ 0← 0↑ 0↑ 0*
combined steps: select(0; A-Z) 3→ ↓ 2→ 2↑ 4→ ↓ 5→ ↑ → ↑ 4← ↑ ← 2↑
That’s a surprising lot of nodes to check, but it works pretty well. And because I have returnFirst: false
, it will always find the optimal solution!
But… wait. How did I do that? Updates to immutable.js
. When we have a solution, record rather than return. When we find a duplicate, update for shorter moves:
// If we have a solution:
// - If we're returning the first solution, return it and steps to get to it
// - If not, record it
if (isSolved(currentState)) {
if (returnFirst) {
return {
state: currentState.toJS(),
steps: stepsTo(currentNode),
iterations,
duration: (Date.now() - startTime) / 1000,
error
}
} else {
solutions = solutions.add(currentState)
}
}
// Otherwise, check:
// - If we've reached a new known state
// - If it's a known state that contains fewer steps
// Either way, do not continue processing
if (visited.has(currentState)) {
let visitedPreviousNode = visited.get(currentState)
if (stepsTo(currentNode).length < stepsTo(visitedPreviousNode).length) {
visited = visited.set(currentState, currentNode)
} else {
currentNode = currentNode.set('previousNode', visitedPreviousNode)
}
continue
} else {
visited = visited.set(currentState, currentNode)
}
At the end, we have to check for the shortest of the solutions
:
// If we've made it this far, check if we have any solutions
// We need to return the shortest one
let shortestSolvedNode = null
if (debug) console.log('SOLUTIONS')
if (debug) console.log(solutions.toJSON())
for (let state of solutions) {
let node = visited.get(state)
if (shortestSolvedNode === null || stepsTo(node).length < stepsTo(shortestSolvedNode).length) {
shortestSolvedNode = node
}
}
let result = {
iterations,
duration: (Date.now() - startTime) / 1000,
error,
}
if (shortestSolvedNode) {
result.state = shortestSolvedNode.state.toJS()
result.steps = stepsTo(shortestSolvedNode)
}
return result
And that’s it. We have a way to find the shortest solution to any snakebird problem!
Now… it doesn’t actually work perfectly. Particularly in the case of multiple snakes, there is an explosion of states that runs out of RAM before we can solve the problem. One solution: throw more RAM at it! But I think we could use a bit more optimization first. Either an ability to persist nodes we haven’t used in a while to disk or a scoring node to order our search better.
That can wait for another day though. Onward!
Output for the first 11 levels (I need optimization for 9):
$ for f in snakebird/*.txt
node snakebird.js --progress 1000000 --timeout 60 $f
end | tee log.txt
===== snakebird/0.txt =====
----------------------
--------##-----*------
--------####----------
----#----##-----------
------#--#---+-#------
---CBA---#-#---##---+-
-#######---##-------##
######################
time taken: 0.425
iterations: 11117
raw steps: start 0→ 0→ 0→ 0↓ 0→ 0→ 0↑ 0↑ 0→ 0→ 0→g 0→ 0↓ 0f 0→ 0→ 0→ 0→ 0→ 0↑ 0→g 0↑ 0← 0← 0← 0← 0↑ 0← 0↑ 0↑ 0*
combined steps: select(0; A-Z) 3→ ↓ 2→ 2↑ 4→ ↓ 5→ ↑ → ↑ 4← ↑ ← 2↑
===== snakebird/1.txt =====
---*--
------
#-----
+--#+#
------
-#BA--
-####-
-####-
-###--
time taken: 0.1
iterations: 1569
raw steps: start 0↑ 0← 0f 0↑ 0← 0← 0↑g 0→ 0f 0→ 0→ 0→ 0f 0↑ 0↑g 0↑ 0← 0↑ 0↑ 0*
combined steps: select(0; A-Z) ↑ ← ↑ 2← ↑ 4→ 3↑ ← 2↑
===== snakebird/2.txt =====
---##-------
---###------
--####--*---
-#####------
-##-+#---C--
-##-----AB-+
###--#####--
#######-##--
#######--#--
time taken: 0.145
iterations: 1849
raw steps: start 0← 0← 0← 0← 0← 0↑ 0f 0→ 0↑g 0← 0f 0↑ 0→ 0f 0→ 0→ 0→ 0→ 0→ 0→ 0→g 0↑ 0← 0← 0f 0← 0↑ 0↑ 0↑ 0*
combined steps: select(0; A-Z) 5← ↑ → ↑ ← ↑ 8→ ↑ 3← 3↑
===== snakebird/3.txt =====
---##-------
---###------
--####--*---
-#####------
-##-+#---C--
-##-----AB-+
###--#####--
#######-##--
#######--#--
time taken: 0.136
iterations: 1849
raw steps: start 0← 0← 0← 0← 0← 0↑ 0f 0→ 0↑g 0← 0f 0↑ 0→ 0f 0→ 0→ 0→ 0→ 0→ 0→ 0→g 0↑ 0← 0← 0f 0← 0↑ 0↑ 0↑ 0*
combined steps: select(0; A-Z) 5← ↑ → ↑ ← ↑ 8→ ↑ 3← 3↑
===== snakebird/4.txt =====
--CBA-*
--D#---
-------
#^---^#
-^-----
-^^#^--
---+---
-----#-
----##-
----##-
time taken: 0.325
iterations: 4836
raw steps: start 0↑ 0← 0← 0f 0↓ 0↓ 0→ 0f 0f 0→ 0→ 0↓ 0↓ 0← 0↓ 0← 0← 0↑ 0→g 0→ 0f 0↑ 0→ 0↑ 0↑ 0← 0← 0↑ 0→ 0↑ 0→ 0→ 0↑ 0↑ 0*
combined steps: select(0; A-Z) ↑ 2← 2↓ 3→ 2↓ ← ↓ 2← ↑ 2→ ↑ → 2↑ 2← ↑ → ↑ 2→ 2↑
===== snakebird/5.txt =====
---*-
-#---
+--+#
-----
^#--#
-BA^^
-C#^-
time taken: 0.244
iterations: 3087
raw steps: start 0↑ 0↑ 0← 0↑ 0→ 0↑ 0f 0→g 0↑ 0f 0f 0→ 0→ 0↑ 0↑ 0← 0← 0← 0↓ 0← 0f 0← 0↑g 0↑ 0↑ 0→ 0→ 0→ 0*
combined steps: select(0; A-Z) 2↑ ← ↑ → ↑ → ↑ 2→ 2↑ 3← ↓ 2← 3↑ 3→
===== snakebird/6.txt =====
---##----
---##----
--##-----
-###-----
--#+-#---
--#-----*
BA---#---
C##^-----
D-###----
time taken: 0.194
iterations: 3553
raw steps: start 0→ 0→ 0→ 0↑ 0→ 0→ 0↑ 0↑ 0← 0← 0↑ 0→ 0→ 0f 0↓ 0↓ 0← 0← 0↑ 0↑ 0↑ 0f 0f 0←g 0↓ 0f 0← 0← 0← 0← 0↑ 0→ 0→ 0f 0→ 0→ 0→ 0↑ 0→ 0→ 0→ 0→ 0*
combined steps: select(0; A-Z) 3→ ↑ 2→ 2↑ 2← ↑ 2→ 2↓ 2← 3↑ ← ↓ 4← ↑ 5→ ↑ 4→
===== snakebird/7.txt =====
------*---
----------
---#------
----------
#---------
#---------
#--#---abc
---#--ABCd
------####
time taken: 22.459
iterations: 526433
raw steps: start 0↑ 0↑ 0→ 0↑ 0← 0← 0f 1↓ 1← 1↑ 0← 0← 1↑ 1↑ 1← 1← 1← 1← 1← 1↑ 1← 0↑ 0↑ 0← 0↑ 0← 0← 1← 1↑ 1↑ 1→ 0f 1f 1→ 1→ 1↑ 1→ 0↑ 0↑ 0→ 0↑ 0→ 0→ 0→ 0→ 0→ 0* 1→ 1→ 1→ 1↑ 1*
combined steps: select(0; A-Z) 2↑ → ↑ 2← select(1; a-z) ↓ ← ↑ select(0; A-Z) 2← select(1; a-z) 2↑ 5← ↑ ← select(0; A-Z) 2↑ ← ↑ 2← select(1; a-z) ← 2↑ 3→ ↑ → select(0; A-Z) 2↑ → ↑ 5→ select(1; a-z) 3→ ↑
===== snakebird/8.txt =====
--------------*-
----------------
--CBA--------###
-EDabc-----^^^##
######^^^--^----
-#####--^^#^----
time taken: 34.044
iterations: 680995
raw steps: start 0↑ 0← 0↑ 0→ 0→ 0f 0↓ 0→ 1↑ 1→ 0→ 1↓ 1→ 0→ 0→ 1→ 0↓ 0→ 0f 1↑ 1→ 1→ 0↑ 1→ 0↑ 1↑ 1→ 1→ 1→ 1→ 0↑ 0↑ 0→ 0→ 0→ 0→ 0* 1→ 1↑ 1*
combined steps: select(0; A-Z) ↑ ← ↑ 2→ ↓ → select(1; a-z) ↑ → select(0; A-Z) → select(1; a-z) ↓ → select(0; A-Z) 2→ select(1; a-z) → select(0; A-Z) ↓ → select(1; a-z) ↑ 2→ select(0; A-Z) ↑ select(1; a-z) → select(0; A-Z) ↑ select(1; a-z) ↑ 4→ select(0; A-Z) 2↑ 4→ select(1; a-z) → ↑
===== snakebird/9.txt =====
----*---
--------
--------
----#^--
--------
--------
---^^---
-#------
-----#--
--------
--------
CBA--ab-
D#####cd
####-##e
time taken: 60.003
iterations: 628280
error: maxTime reached
===== snakebird/10.txt =====
-###-----
####-*---
---#-----
-+-#---D#
-----ABC#
--####-##
---###-#-
-----#-+-
-----#-##
-----#-##
----##-##
----####-
time taken: 0.579
iterations: 16740
raw steps: start 0↑ 0→ 0↑ 0↑ 0f 0↑ 0f 0f 0f 0f 0f 0f 0→g 0→ 0→ 0→ 0↑ 0← 0← 0f 0← 0← 0↑ 0↑ 0↑ 0← 0← 0← 0← 0← 0← 0↑ 0→g 0↑ 0→ 0f 0f 0→ 0→ 0→ 0↑ 0↑ 0↑ 0*
combined steps: select(0; A-Z) ↑ → 3↑ 4→ ↑ 4← 3↑ 6← ↑ → ↑ 4→ 3↑
Pretty cool. Next up, optimization!
Source: