Building Procedural Levels in POSTAL: Onslaught

Posted on: September 27, 2024

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:

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:

  1. The entity will not spawn closer than 3 tiles from the level bounds

  2. The entity must be at least 3 tiles away from any nearby objects/entities

  3. 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:

  1. The object will not spawn closer than 3 tiles from the level bounds

  2. The object must be at least 3 tiles away from any nearby objects/entities

  3. The object must maintain a minimum distance of 3 tiles from the player

  4. 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
The player's housing structure is generated early, even before most other entities and objects are manually spawned. This is because the player is essentially part of the house and we want to prioritize spawning the player before most other elements, just to ensure that anything that needs a reference to the player can likely obtain it!

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!