Intro
A while back, I participated in a game jam, specifically POSTAL Jam 2, which is basically a contest where developers create games inspired by the infamous POSTAL series. I've been a long time fan of POSTAL 2 and it's one of my favorite games of all time, so when I heard that the developers behind the game were hosting a game jam, I just couldn't resist.
One of the standout entries that actually ended up taking second place was POSTAL: Onslaught, a retro arcade-style shoot-em-up that I made for fun inbetween other projects and responsibilities.
The game features randomly generated levels that are made utilizing a custom little scripting language I wrote so I thought it might be interesting to talk about it!
Why a Custom Scripting Language?
Instead of manually designing a set of pre-made levels and randomly picking between them or something boring like that, I decided to challenge myself. Mostly just because I thought it would be fun and I've never done anything like this before previously.
Might not have been the greatest use of my time, but I definitely appreciated it later on in development.
How Does It Work?
The level generation script is stored in what is essentially just a plain text file with a .level
extension, parsed during runtime by my LevelScriptParser
class in Unity from the StreamingAssets
folder. The scripting language was designed to be pretty simple and straightforward to allow me to iterate pretty quickly.
Variables
Variables can be either a whole number/integer or a floating point value, and they can control everything from the size of the level to random placement positions. For instance, to define randomized dimensions of a level, I can simply do this:
VAR LEVEL_SIZE_X RAND_INT 10 50
VAR LEVEL_SIZE_Y RAND_INT 10 50
LEVEL_SIZE_X
and LEVEL_SIZE_Y
will be random integers between 10 and 50 using the RAND_INT
function. These variables can then be used in conditions and loops.
Loops
The scripting language includes FOR
loops, which allow iteration for placing multiple entities or filling areas with tiles and whatnot. Here's an example of a loop that places a row of tiles:
FOR X 0 10
TILE grass_tile X 0
ENDFOR
This loop places a line of grass tiles using the TILE
function along the x-axis from (0,0) to (10,0). The flexibility of loops also allows for more advanced stuff, like filling larger areas with a set of rules.
Conditions and Operators
The language supports conditional logic using IF
statements, enabling dynamic changes based on various conditions.
This allows you to control the behavior of elements within the level.
Conditional statements can use various operators to define the logic.
Below are the supported key operators:
>
- Greater than<
- Less than>=
- Greater than or equal to<=
- Less than or equal to==
- Equal to!=
- Not equal to
Here’s how you can decide whether to place an obstacle:
IF LEVEL_SIZE_X > 30
OBJECT rock_1 5 5
ENDIF
This condition checks if the level width is greater than 30. If true, it spawns a rock at the coordinates (5,5).
Entity and object randomization
Randomness plays a huge role in procedural generation obviously. Alongside random integers and floats, there are specialized functions with parameters for random placement of entities and objects. These parameters ensure that stuff spawns safely away from other things, maintaining balance while preserving a feeling of unpredictability in its placement. For example:
ENTITY_RANDOM civilian_npc dirt 3 3 8
This line spawns a civilian_npc
entity randomly on a dirt
tile. The following values indicate the constraints:
-
The entity will not spawn closer than 3 tiles from the level bounds
-
The entity must be at least 3 tiles away from any nearby objects/entities
-
The entity must maintain a minimum distance of 8 tiles from the player
OBJECT_RANDOM level_palm_tree dirt 3 3 3 OUTSIDE_BOUNDS
This line will randomly spawn a level_palm_tree
on a dirt
tile with certain restrictions:
-
The object will not spawn closer than 3 tiles from the level bounds
-
The object must be at least 3 tiles away from any nearby objects/entities
-
The object must maintain a minimum distance of 3 tiles from the player
-
The presence of the
OUTSIDE_BOUNDS
flag means this tree will spawn outside the playable level bounds for cosmetic purposes only
Structures and Tile Fills
Another feature of the language is the ability to fill areas with tiles or place procedural structures. Using the TILE_FILL
function, I can easily fill a region of the level:
TILE_FILL dirt 0 0 10 10
This will fill a 10x10 area with dirt tiles.
The STRUCTURE
function allows me to place predefined randomized structures:
STRUCTURE player_housing LEVEL_BOUNDS_DIST + 5
This will generate a player housing structure in a random location, ensuring that it is at least 5 tiles away from the boundaries of the level.
Breakdown of the Level Generation Process
1. Setting the Level Dimensions and Boundaries
VAR LEVEL_SIZE_X RAND_INT 64 96
VAR LEVEL_SIZE_Y RAND_INT 64 96
VAR LEVEL_BOUNDS_DIST 13
This section of the script randomizes the dimensions of the level (between 64 and 96 tiles for both width and height). It also defines a boundary distance of 13 tiles, which will be used later to ensure there is a consistent buffer around the edge of the level.
2. Filling the Area with Dirt
TILE_FILL dirt 0 0 LEVEL_SIZE_X-1 LEVEL_SIZE_Y-1
This function fills the entire level with dirt tiles as the base terrain. Every tile in the defined area is initially filled with dirt to essentially set up the rest of the generation...
3. Creating Cliffs along the Level Edges
FOR X LEVEL_BOUNDS_DIST LEVEL_SIZE_X-LEVEL_BOUNDS_DIST-1
TILE dirt_cliff_b X LEVEL_BOUNDS_DIST-1
TILE dirt_cliff_t X LEVEL_SIZE_Y-LEVEL_BOUNDS_DIST-1
ENDFOR
FOR Y LEVEL_BOUNDS_DIST LEVEL_SIZE_Y-LEVEL_BOUNDS_DIST-1
TILE dirt_cliff_l LEVEL_BOUNDS_DIST-1 Y
TILE dirt_cliff_r LEVEL_SIZE_X-LEVEL_BOUNDS_DIST-1 Y
ENDFOR
These loops place cliff tiles around the edges of the level, forming simple boundaries.
TILE dirt_cliff_tl LEVEL_BOUNDS_DIST-1 LEVEL_SIZE_Y-LEVEL_BOUNDS_DIST
TILE dirt_cliff_tr LEVEL_SIZE_X-LEVEL_BOUNDS_DIST LEVEL_SIZE_Y-LEVEL_BOUNDS_DIST
TILE dirt_cliff_bl LEVEL_BOUNDS_DIST-1 LEVEL_BOUNDS_DIST-1
TILE dirt_cliff_br LEVEL_SIZE_X-LEVEL_BOUNDS_DIST LEVEL_BOUNDS_DIST-1
Corner cliffs are then placed at the four corners of the level to finish off the boundary. These cliffs help to separate the playable area from the rest of the map, making it feel more like an arena.
5. Placing Player Housing Structure
STRUCTURE player_housing LEVEL_BOUNDS_DIST + 5
6. Placing Entities and Randomizing NPCs
VAR POLICE_COUNT RAND_INT 1 5
VAR CIVILIAN_COUNT RAND_INT 4 6
VAR PROTESTER_COUNT RAND_INT 1 3
FOR INDEX 1 POLICE_COUNT
ENTITY_RANDOM police_enemy_npc dirt 3 3 8
ENDFOR
FOR INDEX 1 CIVILIAN_COUNT
ENTITY_RANDOM civilian_npc dirt 3 3 8
ENTITY_RANDOM civilian_enemy_npc dirt 3 3 8
ENDFOR
FOR INDEX 1 PROTESTER_COUNT
ENTITY_RANDOM protester_npc dirt 3 3 8
ENDFOR
The number of police and civilian NPCs is randomized between 1-5 and 4-6, and protesters between 1-3 respectively. These values are then used in loops to randomly place them throughout the level using set rules.
7. Adding Random Pickups
FOR INDEX 1 3
ENTITY_RANDOM weapon_pickup dirt 5 3 8
ENDFOR
FOR INDEX 1 3
ENTITY_RANDOM item_pickup dirt 3 3 8
ENDFOR
Weapon and item pickups are scattered across the map randomly. The constraints in the script ensure they do not spawn too close to other entities, like the player, ensuring the level remains balanced.
8. Placing Trees and Other Objects
FOR INDEX 1 5
OBJECT_RANDOM level_palm_tree dirt 2 3 3
ENDFOR
FOR INDEX 1 6
OBJECT_RANDOM level_cactus dirt 2 3 3
ENDFOR
...
Objects like trees and rocks are randomly placed to give the level a more varied and natural appearance. The script ensures that they don't spawn too close to the player or other objects/entities.
9. Placing out-of-bounds Objects
FOR INDEX 1 20
OBJECT_RANDOM level_palm_tree dirt 3 3 3 OUTSIDE_BOUNDS
OBJECT_RANDOM level_cactus dirt 3 3 3 OUTSIDE_BOUNDS
OBJECT_RANDOM level_rock1 dirt 3 3 3 OUTSIDE_BOUNDS
ENDFOR
Cosmetic objects are placed outside the playable area using the OUTSIDE_BOUNDS
flag, ensuring that the surroundings outside the playable area appears populated.
10. Placing more Structures
FOR INDEX 1 2
STRUCTURE npc_housing_01 LEVEL_BOUNDS_DIST + 3
STRUCTURE npc_housing_02 LEVEL_BOUNDS_DIST + 3
STRUCTURE npc_housing_03 LEVEL_BOUNDS_DIST + 3
ENDFOR
Finally, a few procedural structures are randomly placed in the level including various NPC housing, ensuring they maintain a certain distance from the boundaries.
Final Thoughts
Creating this custom scripting language was a bit of a challenge, but it became an essential part of delivering decent enough levels that are fun to play.
I hope this dive into the inner workings of the scripting language was interesting. Thanks for reading!