Level Generation꞉ Drunk Walk - nesbox/TIC-80 GitHub Wiki
Lately I've been interested (<--read "obsessed") with procedural level generation. A method of creating levels where instead of spending time building game levels yourself, you spend time teaching the software to build them for you.
It actually started when I had an idea for a puzzle runner that needed unlimited, random levels. sort of like a bigger, badder version of Push-em
well pretty soon the game became a side project, and writing level generators took precedence! Here's my first, and simplest generator.
It's an algorithm I found called the "Drunk Walk" algorithm. (for obvious reasons)
Here's how it works: fill the level with walls spawn a bot in the area bot walks randomly without leaving the level wherever the bot walks "carve" pathways stop when the desired amount of floor space is reached eliminate unnecessary wall blocks
Now let's look at how to make each step happen
FILL THE LEVEL WITH WALLS
To do this we need to iterate over every cell in the level. I like to log the level area into variables for flexibility:
MINX=0 --start of x cells
MAXX=29 --end of x cells
MINY=0 --start of y cells
MAXY=16 --end of y cells
in this way you can scale your map just by changing these values
now we iterate by using "nested for loops":
--for every x cell
for x=MINX,MAXX do
--for every y cell
for y=MINY,MAXY do
--set the current cell to wall
mset(x,y,WALL)
end
end
"WALL" is just a variable with the id for the wall sprite.
SPAWN A BOT
Spawning a bot in a random spot is super simple. First let's create an easy way to get random numbers:
function r(a,b)
return math.random(a,b)
end
Now we just randomize the bot's (x,y) when we initialize him:
bot={
x=r(MINX+2,MAXX-2)
y=r(MINY+2,MAXX-2)
ld=nil} --last direction...don't worry about it right now
Notice we added added a 2 cell buffer around the border to keep the bot from spawning too close to the edge
BOT WALKS RANDOMLY WITHOUT LEAVING LEVEL
To do this we need four things: a table of directions a function for retrieving a random direction define what "leaving the board" means move the bot without "leaving the board"
TABLE OF DIRECTIONS
DIRS={
{x= 0,y=-1},--up
{x= 0,y= 1},--d0wn
{x=-1,y= 0},--left
{x= 1,y= 0},--right
{x=-1,y= 0},--left
{x= 1,y= 0}}--right
--adding left and right twice makes the
--bot more likely to move sideways
--resulting in a level that fits better
--in the screen
FUNCTION TO RETRIEVE RANDOM DIRECTION
function get_dir()
local td=DIRS[r(1,6)]
if bot.ld==nil then
return td
else
if bot.ld==td then
get_dir()
else
return td
end
end
end
Here I decided to not let the bot pick the same direction he just came from. this makes the bot move around the level instead of dancing back and forth between two tiles
DEFINE "LEAVING THE BOARD"
--check if the move is within the board
--boundaries
function move_is_valid(d)
if bot.x+d.x>MAXX-2 or bot.x+d.x<MINX+1
or bot.y+d.y>MAXY-2 or bot.y+d.y<MINY+1 then
return false
else
return true
end
end
So we can feed the bot's current direction into the function, and it tests if the next cell is out of bounds. From there it returns TRUE or FALSE so we can use it for "if" statement testing.
MOVE THE BOT WITHOUT LEAVING THE BOARD
function move()
--make it 1 in 4 chance for the bot
--to travel in the same direction
if r(1,2)==r(1,2) or d==nil then
d=get_dir()
end
if move_is_valid(d) then
bot.d=d
bot.x=bot.x+d.x
bot.y=bot.y+d.y
end
end
Notice that I made it more likely for the bot to keep moving in his current direction than to change directions. If you leave this out the levels turn out more open with less walkways. Also see how I used the move_is_valid() function.
CARVE PATHWAYS WHERE THE BOT WALKS
to do this we need to add a single line of code into the bot's move() function:
mset(bot.x,bot.y,FLOOR)
We'll put this in before we make him move. So he sets the map cell he's standing on to floor. If he walks over the same cell twice, it's already set to floor so setting it again does nothing. Here's the updated move() function:
function move()
mset(bot.x,bot.y,FLOOR)
--make it 1 in 2 chance for the bot
--to travel in the same direction
if r(1,2)==r(1,2) or d==nil then
d=get_dir()
end
if move_is_valid(d) then
bot.d=d
bot.x=bot.x+d.x
bot.y=bot.y+d.y
end
end
STOP WHEN THE DESIRED AMOUNT OF FLOOR SPACE IS REACHED
To do this we will make a global variable for the amount of floor tiles we want to end up with:
MAX_FLOORS=200 --how many tiles in the level
Now we can iterate over every cell and count how many are floor tiles:
function count()
local temp=0
for x=MINX,MAXX do
for y=MINY,MAXY do
if mget(x,y)==FLOOR then
temp=temp+1
end
end
end
floors=temp
end
Everytime this is called it sets temp to 0, and increments it for every floor tile. Once it's checked every cell it assigns temp to floors. This way we can test how many floor tiles are in the level.
REMOVE UNNECESSARY WALL BLOCKS
The point of this is more or less asthetic. It'll make a clean perimeter by removing the extra wall blocks. The way we do this is pretty simple, once again we iterate over every cell, this time we test if it has any floor tiles immediatly around it. If it does than it's either a floor tile, or a perimeter wall. any cell that doesn't meet this criteria is set to empty space.
function wallbreak()
for x=MINX,MAXX do
for y=MINY,MAXY do
--check adjacent cells
if mget(x-1,y+0)~=FLOOR and --left
mget(x+1,y+0)~=FLOOR and --right
mget(x+0,y+1)~=FLOOR and --bottom
mget(x+0,y-1)~=FLOOR and --top
--also check diagonals
mget(x-1,y-1)~=FLOOR and --top-left
mget(x+1,y-1)~=FLOOR and --top-right
mget(x-1,y+1)~=FLOOR and --bottom-left
mget(x+1,y+1)~=FLOOR then--bottom-right
--set the cell to empty
mset(x,y,0)
end
end
end
end
PUTTING IT ALL TOGETHER
Last we write a function that bundles all of these steps together.
function build_level()
::redo::
move()
count()
if floors<MAX_FLOORS then
goto redo
end
mode="initializing"
end
You can see that as long as the floor count is less than we asked for, it will continue to loop over move() and count(). Be careful about asking for more floor tiles than is in the level because then the bot will be doomed to wander aimlessly for eternity! forever doing a task he can never complete!! OH YOU EVIL OVERLORD!! (plus it'll just crash TIC-80)
After the level is complete, the mode is changed to "initializing". This is the step where we clean up the walls and add anything we need to before letting the player start. (coins, enemyies, etc...)
That's is all the steps involved. Here's the completed code to demonstrate how they're used.
-- title: D-Walk
-- author: Bear Thorne
-- desc: drunk walk algorithm
-- script: lua
---------------------------------------
---beginning of the generator script---
---------------------------------------
--sprite constants
FLOOR=1
WALL=17
DUDE=33
MINX=0
MAXX=29
MINY=0
MAXY=16
MAX_FLOORS=200 --how many tiles in the level
--directions
DIRS={
{x= 0,y=-1},--up
{x= 0,y= 1},--d0wn
{x=-1,y= 0},--left
{x= 1,y= 0},--right
{x=-1,y= 0},--left
{x= 1,y= 0}}--right
--adding left and right twice makes the
--bot more likely to move sideways
--resulting in a level that fits better
--in the screen
function init()
mode="generating"
t=0
bot={
x=r(MINX+2,MAXX-2),
y=r(MINY+2,MAXY-2),
ld=nil}
floors=0
--set the level to all walls
for x=MINX,MAXX do
for y=MINY,MAXY do
mset(x,y,WALL)
end
end
end
--random number shorthand
function r(a,b)
return math.random(a,b)
end
--get a direction from the DIRS table
function get_dir()
local td=DIRS[r(1,6)]
if bot.ld==nil then
return td
else
if bot.ld==td then
get_dir()
else
return td
end
end
end
--check if the move is within the board
--boundaries
function move_is_valid(d)
if bot.x+d.x>MAXX-2 or bot.x+d.x<MINX+1
or bot.y+d.y>MAXY-2 or bot.y+d.y<MINY+1 then
return false
else
return true
end
end
--move the carving bot
function move()
mset(bot.x,bot.y,FLOOR)
--make it 1 in 2 chance for the bot
--to travel in the same direction
if r(1,2)==r(1,2) or d==nil then
d=get_dir()
end
if move_is_valid(d) then
bot.d=d
bot.x=bot.x+d.x
bot.y=bot.y+d.y
end
end
--count the floor tiles in the level
function count()
local temp=0
for x=MINX,MAXX do
for y=MINY,MAXY do
if mget(x,y)==FLOOR then
temp=temp+1
end
end
end
floors=temp
end
--remove large blocks of walls to make
--a clean perimeter
function wallbreak()
for x=MINX,MAXX do
for y=MINY,MAXY do
--check adjacent cells
if mget(x-1,y+0)~=FLOOR and --left
mget(x+1,y+0)~=FLOOR and --right
mget(x+0,y+1)~=FLOOR and --bottom
mget(x+0,y-1)~=FLOOR and --top
--also check diagonals
mget(x-1,y-1)~=FLOOR and --top-left
mget(x+1,y-1)~=FLOOR and --top-right
mget(x-1,y+1)~=FLOOR and --bottom-left
mget(x+1,y+1)~=FLOOR then--bottom-right
--set the cell to empty
mset(x,y,0)
end
end
end
end
function build_level()
::redo::
move()
count()
if floors<MAX_FLOORS then
goto redo
end
mode="initializing"
end
--------------------------
--end of generation code--
--------------------------
--update the onscreen graphics
function draw()
cls()
map(MINX,MINY,MAXX+1,MAXY+1)
end
init()
function TIC()
if mode=="generating" then
--level generation code
build_level()
elseif mode=="initializing" then
--level initializing code
wallbreak()
mode="game"
elseif mode=="game" then
--game code here--
draw()
end
t=t+1
end
As great as this generator turned out, it sadly didn't build suitable levels for my game...So instead I've started making a dungeon crawler built around it!...I'll link to it here when it's done! And the search for that perfect Level Generator continues!!!
Please fell free to use any portion of this code in you own projects! Just throw a credit to me at the top of your file and we'll be cool. :)
if you have questions or requests for future tutorials, email me at [email protected]