This tutorial walks you through the process of creating one of the most common classic games you can attempt to recreate. There are a lot of variations on this game, this one features a snake that eats “food” and that only grows when it eats. This snake also crawls on a playfield that contains obstacles.
Open the game.project settings file and set the dimensions of the game to 768⨉768 or some other multiple of 16. The reason why you want to do this is because the game will be drawn on a grid where each segment is going to be 16x16 pixels, and this way the game screen won’t cut off any partial segments.
Very little is needed in terms of graphics. One 16x16 segment for the snake, one for obstacles and one for the food. This image is the only asset you need. Right click the image, save it to your local disk and drag it to a location in the project folder.
Defold provides a built-in Tilemap component that you will use to create the playfield. A tilemap allows you to set and read individual tiles, which suits this game perfectly. Since tilemaps fetch their graphics from a Tilesource so you need to create one:
Right click the main folder and select New ▸ Tile Source. Name the new file “snake” (the editor will save the file as “snake.tilesource”).
Set the Image property to the graphics file you just imported.
The Width and Height properties should be kept at 16. This will split the 32⨉32 pixel image into 4 tiles, numbered 1–4.
Note that the Extrude Borders property is set to 1 pixel. This is to prevent visual artifacts around the tiles that have graphics all the way out to the edge.
Now you have a tilesource ready for use so it’s time to create the playfield tilemap component:
Right click the main folder and select New ▸ Tile Map. Name the new file “grid” (the editor will save the file as “grid.tilemap”).
Set the Tile Source property of the new tile map to “snake.tilesource”.
Defold only stores the area of the tilemap that is actually used so you need to add enough tiles to fill the boundaries of the screen.
Select the “layer1” layer.
Choose the menu option Edit ▸ Select Tile... to display the tile palette, then click the tile you want to use when painting.
Paint a border around the edge of the screen and some obstacles.
Save the tilemap when you are done.
Now open main.collection. This is the bootstrap collection that is loaded on engine start. Right click the root in the Outline and select Add Game Object which creates a new game object in the collection that is loaded when the game starts.
Then Right click then new game object and select Add Component File. Choose the file “grid.tilemap” that you just created.
Right click the folder main in the Assets browser and select New ▸ Script. Name the new script file “snake” (it is saved as “snake.script”). This file will hold all the logic for the game.
Go back to main.collection and right click then game object holding the tilemap. Select Add Component File and choose the file “snake.script”.
Now you have the tilemap component and the script in place. If you run the game you should see the playfield as you drew it on the tilemap.
The script you are going to write will drive all of the game. The idea for how that is going to work is the following:
Open snake.script and locate the init()
function. This function is called by the engine when the script is initialized on game start. Change the code to the following.
function init(self)
self.segments = {
{x = 7, y = 24},
{x = 8, y = 24},
{x = 9, y = 24},
{x = 10, y = 24} } -- <1>
self.dir = {x = 1, y = 0} -- <2>
self.speed = 7.0 -- <3>
self.t = 0 -- <4>
end
The script code above is written in the Lua language. There are a few things to note about the code:
self
. The self
reference is used to store instance data.{x = 10, y = 20}
), nested Lua tables ({ {a = 1}, {b = 2} a}
) or other data types.self
reference can be used as a Lua table that you can store data in. Just use the dot notation as you would with any other table: self.data = "value"
. The reference is valid throughout the lifetime of the script, in this case from game start until you quit it.If you didn’t understand any of the above, don’t worry about it. Just tag along, experiment and give it time—you will get it eventually.
The init()
function is called exactly once, when the script component is instantiated into the running game. The function update()
, however, is called once each frame, 60 times a second. That makes the function ideal for real time game logic.
The idea for the update is this:
Find the update()
function in snake.script and change the code to the following:
function update(self, dt)
self.t = self.t + dt -- <1>
if self.t >= 1.0 / self.speed then -- <2>
local head = self.segments[#self.segments] -- <3>
local newhead = {x = head.x + self.dir.x, y = head.y + self.dir.y} -- <4>
table.insert(self.segments, newhead) -- <5>
local tail = table.remove(self.segments, 1) -- <6>
tilemap.set_tile("#grid", "layer1", tail.x, tail.y, 0) -- <7>
for i, s in ipairs(self.segments) do -- <8>
tilemap.set_tile("#grid", "layer1", s.x, s.y, 2) -- <9>
end
self.t = 0 -- <10>
end
end
update()
was called.#
is the operator used to get the length of a table given that it is used as an array, which it is—all the segments are table values with no key specified.self.dir
).i
set to the position in the table (starting from 1) and s
set to the current segment.If you run the game now you should see the 4 segment long snake crawl from left to right over the play field.
Before you add code to react to player input, you need to set up the input connections. Find the file input/game.input_binding in the Assets browser and double click to open it. Add a set of Key Trigger bindings for movement up, down, left and right.
The input binding file maps actual user input (keys, mouse movements etc) to action names that are fed to scripts that have requested input. With bindings in place, open snake.script and add the following code:
function init(self)
msg.post(".", "acquire_input_focus") -- <1>
self.segments = {
{x = 7, y = 24},
{x = 8, y = 24},
{x = 9, y = 24},
{x = 10, y = 24} }
self.dir = {x = 1, y = 0}
self.speed = 7.0
self.t = 0
end
function on_input(self, action_id, action)
if action_id == hash("up") and action.pressed then -- <1>
self.dir.x = 0 -- <2>
self.dir.y = 1
elseif action_id == hash("down") and action.pressed then
self.dir.x = 0
self.dir.y = -1
elseif action_id == hash("left") and action.pressed then
self.dir.x = -1
self.dir.y = 0
elseif action_id == hash("right") and action.pressed then
self.dir.x = 1
self.dir.y = 0
end
end
action
table has the pressed
field set to true
(player pressed the key) then:Run the game again and check that you are able to steer the snake.
Now, notice that if you press two keys simultaneously, that will result in two calls to on_input()
, one for each press. As the code is written above, only the call that happens last will have an effect on the snake’s direction since subsequent calls to on_input()
will overwrite the values in self.dir
.
Also note that if the snake moves left and you press the right key, the snake will steer into itself. The apparently obvious fix to this problem is by adding an additional condition to the if
clauses in on_input()
:
if action_id == hash("up") and self.dir.y ~= -1 and action.pressed then
...
elseif action_id == hash("down") and self.dir.y ~= 1 and action.pressed then
...
However, if the snake is moving left and the playes quickly presses first up, then right before the next movement step happens, only the right press will have an effect and the snake will move into itself. With the conditions added to the if
clauses shown above, the input will be ignored. Not good!
A proper solution to this problem is to store the input in a queue and pull entries from that queue as the snake moves:
function init(self)
msg.post(".", "acquire_input_focus")
self.segments = {
{x = 7, y = 24},
{x = 8, y = 24},
{x = 9, y = 24},
{x = 10, y = 24} }
self.dir = {x = 1, y = 0}
self.dirqueue = {} -- <1>
self.speed = 7.0
self.t = 0
end
function update(self, dt)
self.t = self.t + dt
if self.t >= 1.0 / self.speed then
local newdir = table.remove(self.dirqueue, 1) -- <2>
if newdir then
local opposite = newdir.x == -self.dir.x or newdir.y == -self.dir.y -- <3>
if not opposite then
self.dir = newdir -- <4>
end
end
local head = self.segments[#self.segments]
local newhead = {x = head.x + self.dir.x, y = head.y + self.dir.y}
table.insert(self.segments, newhead)
local tail = table.remove(self.segments, 1)
tilemap.set_tile("#grid", "layer1", tail.x, tail.y, 0)
for i, s in ipairs(self.segments) do
tilemap.set_tile("#grid", "layer1", s.x, s.y, 2)
end
self.t = 0
end
end
function on_input(self, action_id, action)
if action_id == hash("up") and action.pressed then
table.insert(self.dirqueue, {x = 0, y = 1}) -- <5>
elseif action_id == hash("down") and action.pressed then
table.insert(self.dirqueue, {x = 0, y = -1})
elseif action_id == hash("left") and action.pressed then
table.insert(self.dirqueue, {x = -1, y = 0})
elseif action_id == hash("right") and action.pressed then
table.insert(self.dirqueue, {x = 1, y = 0})
end
end
newdir
is not null) then check if newdir
is pointing opposite to self.dir
.self.dir
directly.Start the game and check that it plays as expected.
The snake needs food on the map so it can grow long and fast. Let’s add that!
local function put_food(self) -- <1>
self.food = {x = math.random(2, 47), y = math.random(2, 47)} -- <2>
tilemap.set_tile("#grid", "layer1", self.food.x, self.food.y, 3) -- <3>
end
function init(self)
msg.post(".", "acquire_input_focus")
self.segments = {
{x = 7, y = 24},
{x = 8, y = 24},
{x = 9, y = 24},
{x = 10, y = 24} }
self.dir = {x = 1, y = 0}
self.dirqueue = {}
self.speed = 7.0
self.t = 0
math.randomseed(socket.gettime()) -- <4>
put_food(self) -- <5>
end
put_food()
that puts a piece of food on the map.self.food
.math.random()
, set the random seed, otherwise the same series of random values will be generated. This seed should only be set once.put_food()
at game start so the player begins with a food item on the map.Now, detecting if the snake has collided with something is just a matter of looking at what’s on the tilemap where snake is heading and react. Add a variable that keeps track of whether the snake is alive or not:
function init(self)
msg.post(".", "acquire_input_focus")
self.segments = {
{x = 7, y = 24},
{x = 8, y = 24},
{x = 9, y = 24},
{x = 10, y = 24} }
self.dir = {x = 1, y = 0}
self.dirqueue = {}
self.speed = 7.0
self.alive = true -- <1>
self.t = 0
math.randomseed(socket.gettime())
put_food(self)
end
Then add logic that tests for collision with wall/obstacle and food:
function update(self, dt)
self.t = self.t + dt
if self.t >= 1.0 / self.speed and self.alive then -- <1>
local newdir = table.remove(self.dirqueue, 1)
if newdir then
local opposite = newdir.x == -self.dir.x or newdir.y == -self.dir.y
if not opposite then
self.dir = newdir
end
end
local head = self.segments[#self.segments]
local newhead = {x = head.x + self.dir.x, y = head.y + self.dir.y}
table.insert(self.segments, newhead)
local tile = tilemap.get_tile("#grid", "layer1", newhead.x, newhead.y) -- <2>
if tile == 2 or tile == 4 then
self.alive = false -- <3>
elseif tile == 3 then
self.speed = self.speed + 1 -- <4>
put_food(self)
else
local tail = table.remove(self.segments, 1) -- <5>
tilemap.set_tile("#grid", "layer1", tail.x, tail.y, 1)
end
for i, s in ipairs(self.segments) do
tilemap.set_tile("#grid", "layer1", s.x, s.y, 2)
end
self.t = 0
end
end
Now try the game and make sure it plays well!
This concludes the tutorial but please continue experimenting with the game and work through some of the exercises below!
Here is the complete script code for reference:
local function put_food(self)
self.food = {x = math.random(2, 47), y = math.random(2, 47)}
tilemap.set_tile("#grid", "layer1", self.food.x, self.food.y, 3)
end
function init(self)
msg.post(".", "acquire_input_focus")
self.segments = {
{x = 7, y = 24},
{x = 8, y = 24},
{x = 9, y = 24},
{x = 10, y = 24} }
self.dir = {x = 1, y = 0}
self.dirqueue = {}
self.speed = 7.0
self.alive = true
self.t = 0
math.randomseed(socket.gettime())
put_food(self)
end
function update(self, dt)
self.t = self.t + dt
if self.t >= 1.0 / self.speed and self.alive then
local newdir = table.remove(self.dirqueue, 1)
if newdir then
local opposite = newdir.x == -self.dir.x or newdir.y == -self.dir.y
if not opposite then
self.dir = newdir
end
end
local head = self.segments[#self.segments]
local newhead = {x = head.x + self.dir.x, y = head.y + self.dir.y}
table.insert(self.segments, newhead)
local tile = tilemap.get_tile("#grid", "layer1", newhead.x, newhead.y)
if tile == 2 or tile == 4 then
self.alive = false
elseif tile == 3 then
self.speed = self.speed + 1
put_food(self)
else
local tail = table.remove(self.segments, 1)
tilemap.set_tile("#grid", "layer1", tail.x, tail.y, 1)
end
for i, s in ipairs(self.segments) do
tilemap.set_tile("#grid", "layer1", s.x, s.y, 2)
end
self.t = 0
end
end
function on_input(self, action_id, action)
if action_id == hash("up") and action.pressed then
table.insert(self.dirqueue, {x = 0, y = 1})
elseif action_id == hash("down") and action.pressed then
table.insert(self.dirqueue, {x = 0, y = -1})
elseif action_id == hash("left") and action.pressed then
table.insert(self.dirqueue, {x = -1, y = 0})
elseif action_id == hash("right") and action.pressed then
table.insert(self.dirqueue, {x = 1, y = 0})
end
end
The playable game at the top of this tutorial contains some additional improvements. It is a good exercise to try to implement those improvements:
put_food()
does not take into account the position of the snake, nor where obstacles are. Fix that.Did you spot an error or do you have a suggestion? Please let us know on GitHub!
GITHUB