Simon Gust
Member
GM Version: GM:S 2.3
Target Platform: All
Download: N/A
Links: N/A
Summary:
This tutorial is about randomly generated terrain. It shows you what the purpose and effectiveness of tile runners are and how to use them.
Heads up:
This tutorial requires you to know at least the following terms
- arrays
- for loops
- repeat loops
- while loops
- basic math
Tutorial:
PART 1, your basic twisted line:
PART 2, the tile brush:
PART 3: setting runner properties
Selection Runners
Wave Runners
Community tricks
Target Platform: All
Download: N/A
Links: N/A
Summary:
This tutorial is about randomly generated terrain. It shows you what the purpose and effectiveness of tile runners are and how to use them.
Heads up:
This tutorial requires you to know at least the following terms
- arrays
- for loops
- repeat loops
- while loops
- basic math
Tutorial:
PART 1, your basic twisted line:
Tile runners are used to make randomly generated terrain and are extremely wide in their use, ranging from caves to rivers.
Almost anything can be generated with a tile runner if you use it right. Think of it as a worm that eats anything in it’s path.
First, you must understand how they work.
Imagine your terrain as an array where «1» in a cell means that there is solid terrain and «0» meaning that there is nothing but thin air.
Something like seen in this tutorial: https://forum.yoyogames.com/index.php?threads/cellular-automata-cave-generation.37895/
The outcomes are similar but the methods are two different worlds.
Example of a simple generated terrain that shows caves going all over the place like spaghetti
The most basic tilerunner is simple, it starts somewhere, eats some tiles and moves somewhere else,
just to repeat it’s process over and over, until either the compiler crashes due to many recursions or you set it a limit / goal.
Let’s start by setting up a template.
CREATE
Now, apply the tile runner to that array
Also create a surface so we can see the results pasted to the screen
The draw event simply looks like this
And finally, the tile runner script
The strength of the runner acts as a diameter the runner will eat the terrain.
If the strength is 5, you can expect a 5 wide / tall tunnel to be made.
Now to this code snippet
It’s only purpose right now is to make the cave have rounded edges if so desired.
Without it, you would get box tunnels which can work but don’t look really natural.
The math behind this is simple:
It checks the distance to every cell it’s going to eat, then it asks them if they’re in the radius of the center.
The strength suggests to eat away the whole box, but the code snippet sees the distance of the orange cells as too far and doesn’t eat them.
The green cells are in the radius from the center and are eaten away correctly.
No matter how great the strength of your runner, the rounding of the edges is always ultra-precise.
So lets see the result: Pretty cool huh?
Almost anything can be generated with a tile runner if you use it right. Think of it as a worm that eats anything in it’s path.
First, you must understand how they work.
Imagine your terrain as an array where «1» in a cell means that there is solid terrain and «0» meaning that there is nothing but thin air.
Something like seen in this tutorial: https://forum.yoyogames.com/index.php?threads/cellular-automata-cave-generation.37895/
The outcomes are similar but the methods are two different worlds.
Example of a simple generated terrain that shows caves going all over the place like spaghetti
The most basic tilerunner is simple, it starts somewhere, eats some tiles and moves somewhere else,
just to repeat it’s process over and over, until either the compiler crashes due to many recursions or you set it a limit / goal.
Let’s start by setting up a template.
CREATE
GML:
Randomize(); // call this so the outcome is different every compile
wdt = 300; // width of the terrain
hgt = 300; // height of the terrain
global.tile = array_create(wdt, -1);
for (var i = 0; i < wdt; i++) {
global.tile[i] = array_create(hgt, 1); // 1 being solid terrain
}
GML:
// tile_runner(array name, start x, start y, runner strength, runner life)
tile_runner(global.tile, 150, 150, 5, 100);
GML:
surface = surface_create(wdt, hgt); // create the surface
surface_set_target(surface); // set the surface as the current render target
draw_clear(c_white); // clear the surface with a white color
draw_set_color(c_black); // set the terrain color
for (var i = 0; i < wdt; i++)
{
for (var j = 0; j < hgt; j++)
{
if (global.tile[i][j] > 0)
{
draw_point(i, j); // draw a pixel if there is solid terrain
}
}
}
surface_reset_target(); // reset the render target
GML:
draw_surface(surface, 0, 0);
GML:
function tile_runner(array, x, y, strength, life, dir=random(360))
// run this as long as life span
repeat (life)
{
// keeping the runner inbounds to avoid out-of-bounds errors
var x1 = max(x - 0.5 * strength, 0);
var y1 = max(y - 0.5 * strength, 0);
var x2 = min(x + 0.5 * strength, wdt);
var y2 = min(y + 0.5 * strength, hgt);
// eat away at the terrain
for (var i = x1; i < x2; i++)
{
for (var j = y1; j < y2; j++)
{
if (array[i][j] > 0) // if there isn’t already nothing, reduce it to nothing
{
if (point_distance(x, y, i, j) < 0.5 * strength) // this line is explained below
{
array[i][j] = 0;
}
}
}
}
// move the runner in a random direction
if (!random(3))
{
dir += random_range(45, -45); // don’t allow the runner to make sharp corners by limiting it to 45° degree turns
}
// move the runner
x += lengthdir_x(1.00, dir);
y += lengthdir_y(1.00, dir);
}
If the strength is 5, you can expect a 5 wide / tall tunnel to be made.
Now to this code snippet
Code:
if (point_distance(x, y, i, j) < 0.5 * strength)
Without it, you would get box tunnels which can work but don’t look really natural.
The math behind this is simple:
It checks the distance to every cell it’s going to eat, then it asks them if they’re in the radius of the center.
The strength suggests to eat away the whole box, but the code snippet sees the distance of the orange cells as too far and doesn’t eat them.
The green cells are in the radius from the center and are eaten away correctly.
No matter how great the strength of your runner, the rounding of the edges is always ultra-precise.
So lets see the result: Pretty cool huh?
PART 2, the tile brush:
In the first part you were shown how to make the roundness in the runner for smooth endings in caves.
This time, you get to see what other (geometry) brushes can have.
First, welcome the diamond brush:
This brush eats the terrain in a diamond shape and it even needs less time to do so, there are 2 reasons for this:
1. There are fewer green cells than with the circle brush.
2. The geometry requires a different distance check which is more efficient.
For this to work, the only thing you need to do is replace this line
with this one
This new calculation is explained here https://en.wikipedia.org/wiki/Taxicab_geometry
I like to call it "Manhattan distance".
Let's test it with a greater strength to better notice the difference.
The most simplest brush though is the one where you don't use a brush at all.
If you leave out that line of code, your brush is simply rectangular.
This works well for corridors or dungeons.
Of course, you can always invent your own brush. But do so at your own risk.
You may have to change the radius to not get flat edges when you try something like this
in which case you'll get an elipse for example.
As a base to making your own brush, use the point_distance math:
You can alter this code and see what happens to your brush shape.
The next thing that might serve usefulness to you is random brush imprecision.
Instead of taking the radius of the brush for granted, you can patch it with a
random_range function. Try this:
This is the result:
Fast brush (kept for refrence)
Octagon brush
You can get a semi-round brush if you combine the rectangular brush with the diamon brush.
The code is the same for the diamond brush except the radius to check is greater.
I changed the 0.5 to a 0.75 and your brush should look something like this
Technically, it is just a bigger diamond shape, but the edges are cut due to the double for loop range limitation.
This time, you get to see what other (geometry) brushes can have.
First, welcome the diamond brush:
This brush eats the terrain in a diamond shape and it even needs less time to do so, there are 2 reasons for this:
1. There are fewer green cells than with the circle brush.
2. The geometry requires a different distance check which is more efficient.
For this to work, the only thing you need to do is replace this line
Code:
if (point_distance(x, y, i, j) < strength * 0.5)
Code:
if (abs(i-x) + abs(j-y) < strength * 0.5)
I like to call it "Manhattan distance".
Let's test it with a greater strength to better notice the difference.
The most simplest brush though is the one where you don't use a brush at all.
If you leave out that line of code, your brush is simply rectangular.
This works well for corridors or dungeons.
Of course, you can always invent your own brush. But do so at your own risk.
You may have to change the radius to not get flat edges when you try something like this
Code:
if (sqrt(sqr(i-x) + 6 * sqr(j-y)) < 0.50 * strength)
As a base to making your own brush, use the point_distance math:
Code:
sqrt(sqr(i-x) + sqr(j-y))
The next thing that might serve usefulness to you is random brush imprecision.
Instead of taking the radius of the brush for granted, you can patch it with a
random_range function. Try this:
Code:
if (point_distance(x, y, i, j) < strength * random_range(0.2, 0.8))
Fast brush (kept for refrence)
And finally, there is the fast brush.
This brush works a little different as it no longer serves the purpose as a brush to shape your caves
but rather as an optimization.
The old brush code line now checks if the array cells of the new position of the runner don't touch the old positions. It only writes the array on the green cells and leaves out the yellow cells.
Red being the old runner position and Green the new runner position.
Since this is a geometry type of check, it's going to be faster than asking every cell if they've been overwritten.
To get this to work, you need to do some code adding:
You need to save and update the last position of the runner each iteration.
initialize xlast and ylast to something very different at the start.
Then at the bottom in the while loop BEFORE you've moved the runner, update the last position to the new position.
You can see that the > has been changed to a < on the brush line.
A problem arises when you choose the number to check the distance for.
Best case scenario would be 0.50 x strength (exactly outside the old radius).
But because array indexes don't work on decimals, the imprecision makes it not work reliably.
A safer number, that I've chosen is 0.40 just to make sure every cell is eaten.
The more strength your runner has, the preciser you can go, up to 0.50.
This also causes the brush to lose it's shape and you end up with a rectangular brush again.
To fix this you can add another bit of code to that line if you want.
Whether you remove the read-write check (if (array[j] > 0) is up to you.
The more runners you call, the more use you get out of this line.
Because the brush checks the cells only on it's last position and only on it's own runner.
This brush works a little different as it no longer serves the purpose as a brush to shape your caves
but rather as an optimization.
The old brush code line now checks if the array cells of the new position of the runner don't touch the old positions. It only writes the array on the green cells and leaves out the yellow cells.
Red being the old runner position and Green the new runner position.
Since this is a geometry type of check, it's going to be faster than asking every cell if they've been overwritten.
To get this to work, you need to do some code adding:
You need to save and update the last position of the runner each iteration.
initialize xlast and ylast to something very different at the start.
Then at the bottom in the while loop BEFORE you've moved the runner, update the last position to the new position.
GML:
function tile_runner(array, x, y, strength, life, dir=random(360))
var xlast = -10000;
var ylast = -10000;
// run this as long as life
repeat (life)
{
// keeping the runner inbounds to avoid out-of-bounds errors
var x1 = max(x - 0.5 * strength, 0);
var y1 = max(y - 0.5 * strength, 0);
var x2 = min(x + 0.5 * strength, wdt);
var y2 = min(y + 0.5 * strength, hgt);
// eat away at the terrain
for (var i = x1; i < x2; i++)
{
for (var j = y1; j < y2; j++)
{
if (point_distance(xlast, ylast, i, j) > 0.40 * strength)
{
array[i][j] = 0;
}
}
}
// move the runner in a random direction
if (!random(3))
{
dir += random_range(45, -45); // don’t allow the runner to make sharp corners by limiting it to 45° degree turns
}
// now update the last position
xlast = x;
ylast = y;
// move the runner
x += lengthdir_x(1.00, dir);
y += lengthdir_y(1.00, dir);
}
A problem arises when you choose the number to check the distance for.
Best case scenario would be 0.50 x strength (exactly outside the old radius).
But because array indexes don't work on decimals, the imprecision makes it not work reliably.
A safer number, that I've chosen is 0.40 just to make sure every cell is eaten.
The more strength your runner has, the preciser you can go, up to 0.50.
This also causes the brush to lose it's shape and you end up with a rectangular brush again.
To fix this you can add another bit of code to that line if you want.
Code:
var dis = point_distance(xlast, ylast, i, j); // or abs(i-xlast) + abs(j-ylast);
if (dis > 0.40 * strength && dis > 0.5 * strength)
The more runners you call, the more use you get out of this line.
Because the brush checks the cells only on it's last position and only on it's own runner.
Octagon brush
You can get a semi-round brush if you combine the rectangular brush with the diamon brush.
The code is the same for the diamond brush except the radius to check is greater.
Code:
if (abs(i-x) + abs(j-y) < strength* 0.75)
Technically, it is just a bigger diamond shape, but the edges are cut due to the double for loop range limitation.
PART 3: setting runner properties
To make your caves / rivers / dugeons or whatever more interesting you can add certain rules to your runner.
These rules can be soley math or random chance of something happening.
The setup is quite easy, so I'll show some examples.
the strength over steps property makes it so that the runner starts at 100% strength and ends at 0% when it's life has ran out.
The math involved looks like this
You need to define a current_step and current_strength variable to keep track of your brush size.
The result can look like this
You can also make some variation happen in your runner like this:
Put them anywhere in the while loop. Just make sure not to create an infinite loop.
This example procs on a 5 percent chance to increase strength by 10, life_current by 10 and reverse the direction.
You can add "wind" to your runner. You want to have your runner go into a set direction.
Add some speed variables and if you want, also let them be arguments for your script.
The last 2 lines now always make the position move by your speed on top of the random direction the runner takes.
Of course, you can also make events happen like splitting a runner into 2 shorter runners or
reverse the direction of a runner at the end of it's lifetime and recurse it.
This is dangerous though as now you're giving the runner the ability to live forever and freezing your generation process.
It's important to add a check or a fail save that keeps track of recursions.
This tracker must be an instance variable as it should not be reset inside the runner script as a local variable.
Example:
If there are recursions left, the runner starts from where the old runner died.
https://imgur.com/a/9xiI3ar
These rules can be soley math or random chance of something happening.
The setup is quite easy, so I'll show some examples.
the strength over steps property makes it so that the runner starts at 100% strength and ends at 0% when it's life has ran out.
The math involved looks like this
Code:
real_strength = strength * (current_step / step_amount);
GML:
var strength_real = strength;
var life_current = life;
while (life_current-- > 0)
{
// calc real strength
strength_real = strength * (life_current / life);
// runner body
}
You can also make some variation happen in your runner like this:
Code:
if (!random(10))
{
strength += 10;
life_current += 10;
dir = -dir;
}
This example procs on a 5 percent chance to increase strength by 10, life_current by 10 and reverse the direction.
You can add "wind" to your runner. You want to have your runner go into a set direction.
Add some speed variables and if you want, also let them be arguments for your script.
GML:
function tile_runner(array, x, y, strength, life, dir=random(360), xspd=0, yspd=0)
repeat (life)
{
// runner body
// move the runner
x += xspd + lengthdir_x(1.00, dir);
y += yspd + lengthdir_y(1.00, dir);
}
Code:
repeat (10)
{
tile_runner(global.tile, 150, 50, 10, 100, 0, 2);
// tile_runner(array, startx, starty, strength, steps, xspd, yspd)
}
Of course, you can also make events happen like splitting a runner into 2 shorter runners or
reverse the direction of a runner at the end of it's lifetime and recurse it.
This is dangerous though as now you're giving the runner the ability to live forever and freezing your generation process.
It's important to add a check or a fail save that keeps track of recursions.
This tracker must be an instance variable as it should not be reset inside the runner script as a local variable.
Example:
Code:
recursions = 10;
tile_runner(global.tile, 150, 50, 10, 100, 340);
// tile_runner(array, startx, starty, strength, steps, dir)
GML:
function tile_runner(array, x, y, strength, life, dir=random(360), recursions)
repeat (life)
{
// runner body
}
// more runners
if (recursions > 0) {
tile_runner(array, x, y, strength, life * random_range(1.20, 0.80), dir + choose(90, -90), --recursions);
}
https://imgur.com/a/9xiI3ar
Selection Runners
Selection runners are tile runners that work less on math and more on user input.
The runner chooses from several patterns that are set by the user.
I've prepared 5 patterns for this example
0: go straight
1: make a slow turn clockwise
2: make a slow turn anti clockwise
3: make a sharp turn clockwise
4: make a sharp turn anti clockwise
The runner will choose one of these but not entirely random.
A chance variable will keep track of how likely the runner is to switch off from a pattern.
In code form it looks like this
It is simply put inside the runner though it doesn't go unnoticed in time cost to execute this every step of the runner.
I've put some thinking in the curve types aswell.
Notice how the sharper turns have a higher chance to be rerolled, this is to prevent revolutions.
The first curve type makes sure that straight paths don't get to long by increasing the chance to change the curve type every step.
The outcome is this:
https://imgur.com/a/8PbPwBl
The runner chooses from several patterns that are set by the user.
I've prepared 5 patterns for this example
0: go straight
1: make a slow turn clockwise
2: make a slow turn anti clockwise
3: make a sharp turn clockwise
4: make a sharp turn anti clockwise
The runner will choose one of these but not entirely random.
A chance variable will keep track of how likely the runner is to switch off from a pattern.
In code form it looks like this
GML:
// select 1 of 5 curve types
if (!random(chance)) {
curve = irandom(4);
}
// evaluate new direction via curve type
switch (curve)
{
case 0: chance--; break;
case 1: dir += 1.00; chance = 15; break;
case 2: dir -= 1.00; chance = 15; break;
case 3: dir += 2.00; chance = 8; break;
case 4: dir -= 2.00; chance = 8; break;
}
I've put some thinking in the curve types aswell.
Notice how the sharper turns have a higher chance to be rerolled, this is to prevent revolutions.
The first curve type makes sure that straight paths don't get to long by increasing the chance to change the curve type every step.
The outcome is this:
https://imgur.com/a/8PbPwBl
Wave Runners
Wave runners are tile runners that curve in a sinus wave.
The user can determine how many revolutions the wave makes and how high the waves get.
dps stands for degrees per step and is required for the runner to know how many degrees
it has to change so that all sinus wave revolutions can be done until the runner dies.
I've included certain randomness to the main path so that it wouldn't look too unrealistic.
The outcome
https://imgur.com/a/la1tfG5
The user can determine how many revolutions the wave makes and how high the waves get.
Code:
wave_runner(global.tile, 500, 500, 10, 1000, 4, 80);
GML:
function wave_runner(array, x, y, strength, life, dir=random(360), revolutions, height)
var dps = (360 / life) * revolutions;
repeat (life)
{
// runner body
// sinus curve
var sinus = life * dps;
var real_dir = dir + lengthdir_x(height, sinus);
// move the runner
x += lengthdir_x(1.00, real_dir);
y += lengthdir_y(1.00, real_dir);
}
it has to change so that all sinus wave revolutions can be done until the runner dies.
I've included certain randomness to the main path so that it wouldn't look too unrealistic.
The outcome
https://imgur.com/a/la1tfG5
Community tricks
by GMWolf:
A faster way to achieve alteration tothe circular brush is using
over
as now, it is no longer necessary to calculate the square root of one if we can square the other instead.
Important is however, that you include the 0.5 as well inside the square function.
by GMWolf:
It is faster to first check for the tile's existence before doing heavier distance calculation with them.
by Me:
Apparently, doing the limit checks with if-statements is faster than doing them with the max and min functions. This is unfortunate because I really like the format of min and max.
A faster way to achieve alteration tothe circular brush is using
Code:
if (sqr(i - x) + sqr(j - y) < sqr(0.5 * strength))
Code:
if (sqrt(sqr(i - x) + sqr(j - y)) < 0.5 * strength)
Important is however, that you include the 0.5 as well inside the square function.
by GMWolf:
It is faster to first check for the tile's existence before doing heavier distance calculation with them.
by Me:
Apparently, doing the limit checks with if-statements is faster than doing them with the max and min functions. This is unfortunate because I really like the format of min and max.
Code:
if (x1 < 0) x1 = 0;
if (y1 < 0) y1 = 0;
if (x2 > w) x2 = w;
if (y2 > h) y2 = h;
Last edited: