Procedurally generated maps in video games has become a hot topic in the recent years among the gaming community. the algorithms for generating these maps are extremely complected, and requires months of development to build such algorithms. Instead of symbolically written programs, what if we can use generative models to generate these maps?
In this Tutorial we will show you how you can generate game maps for a simple game where the map can be expressed using a list of strings.
A level can be represented in this hypothetical game using a list of strings shown below.
1linkglob level = [ 'BBBBBBBBBB',
2link 'B....E...B',
3link 'B.P......B',
4link 'B.....B..B',
5link 'B.....BE.B',
6link 'B....BBBBB',
7link 'B....B...B',
8link 'B.....E..B',
9link 'BBBBBBBBBB' ];
In this level each character represent a different element in the map,
The straightforward approach to build a map generator is to ask from the LLM to directly generate such a map as a list of strings. MTLLM allows you to do this by defining a function or a method. However, here we would discuss a more object oriented way of programming with LLMs which allow the model to 'think' using objects.
1linkobj Level {
2link has name: str,
3link difficulty: int;
4link has width: int,
5link height: int,
6link num_wall: int,
7link num_enemies: int;
8link has time_countdown: int,
9link n_retries_allowed: int;
10link}
Each level should have a basic configuration which describes the level in an abstract format. This level object embeds the difficulty of the level and the number of enemies and obstacles including other level configuration parameters.
However, filling in the values for fields requires a cognitive capacity, for which will use an LLM later on.
1linkobj Position {
2link has x: int,
3link y: int;
4link}
As the map we are aiming to generate is a 2D map the position of each object on the map can be designated using the Position
custom type. It is simply representing a cartesian 2D coordinate system.
1linkobj Wall {
2link has start_pos: Position,
3link end_pos: Position;
4link}
The wall object represents a straight wall, as all obstacles in the 2D map can be represented by a collection of intersecting wall objects. Here each wall object will have a start position as well as a stop position
1linkobj Map {
2link has level: Level;
3link has walls: list[Wall],
4link small_obstacles: list[Position];
5link has enemies: list[Position];
6link has player_pos: Position;
7link}
This Map object will hold the exact positions of all objects in the map. This is the object that we will generate using MT-LLM. Each field of this object is one of or a derivative of the custom types which we described above.
To manage all the generations we can define a Level manager object which can hold a directory of previous levels configurations and maps, which can be used to feed the LLM to give context about the play style of the player. We will be using the OpenAI GPT-4o as the LLM in this tutorial.
1linkimport:py from mtllm.llms, OpenAI;
2linkglob llm = OpenAI(model_name="gpt-4o");
3link
4linkobj LevelManager {
5link has current_level: int = 0,
6link current_difficulty: int = 1,
7link prev_levels: list[Level] = [],
8link prev_level_maps: list[Map] = [];
9link
10link '''Generates the Next level configuration based upon previous playthroughs'''
11link can create_next_level( last_levels: list[Level],
12link difficulty: int,
13link level_width: int,
14link level_height: int) -> Level by llm(temperature=1.0);
15link
16link '''Get the Next Level'''
17link can get_next_level -> tuple(Level, Map);
18link
19link '''Generate the Map as a List of Strings'''
20link can get_map(map: Map) -> list[str];
21link}
We have three methods defined under the level manager. Each will handle a separate set of tasks.
create_next_level
: Takes in previous level configuration data from previously played levels and generate the new level configuration parameters and output a Level
object which describes the new map, using the LLM.
get_next_level
: Uses the create_next_level
to generate the Level
config. object which is then used to fill in the rest of a newly initiated Map
object using an LLM. This is where the actual map generation happens. Still the generated map cannot be visualize.
get_map
: This method will generate the actual list of strings which can be used with an actual game using the Map
object generated by get_next_level
method. This does not require any LLM as all objects of the map are included in the Map
object with their exact positions.
The implementation of the above methods are as follows.
1link:obj:LevelManager:can:get_next_level {
2link self.current_level += 1;
3link # Keeping Only the Last 3 Levels
4link if len(self.prev_levels) > 3 {
5link self.prev_levels.pop(0);
6link self.prev_level_maps.pop(0);
7link }
8link # Generating the New Level
9link new_level = self.create_next_level(
10link self.prev_levels,
11link self.current_difficulty,
12link 20, 20
13link );
14link self.prev_levels.append(new_level);
15link # Generating the Map of the New Level
16link new_level_map = Map(level=new_level by llm());
17link self.prev_level_maps.append(new_level_map);
18link # Increasing the Difficulty for end of every 2 Levels
19link if self.current_level % 2 == 0 {
20link self.current_difficulty += 1;
21link }
22link return (new_level, new_level_map);
23link}
In the get_next_level
method there are two llm calls which we will discuss in this tutorial while other parts are related the functionality of the game.
Line 9-13
: Here the saved data from previous levels are given as inputs which are defined previously along with the basic level config parameters of the new level. As the output type of this method was specified above to be a Level
object the LLM will initiate and fill in the values of the objects. As the LLM hyperparameter temperature, is set for 1.0 at method declaration, the LLM is forced to be more creative.
Line 14
: Here the programmer is initiating a Map object while passing in only the level parameter with the newly generated level
object and ask the LLM to fill in the rest of the fields by generating the relevant types. This nested type approach ensures the output is formatted according to how you expect them to be.
1link:obj:LevelManager:can:get_map {
2link map_tiles = [['.' for _ in range(map.level.width)] for _ in range(map.level.height)];
3link for wall in map.walls {
4link for x in range(wall.start_pos.x, wall.end_pos.x + 1) {
5link for y in range(wall.start_pos.y, wall.end_pos.y + 1) {
6link map_tiles[y-1][x-1] = 'B';
7link }
8link }
9link }
10link for obs in map.small_obstacles {
11link map_tiles[obs.y-1][obs.x-1] = 'B';
12link }
13link
14link for enemy in map.enemies {
15link map_tiles[enemy.y-1][enemy.x-1] = 'E';
16link }
17link map_tiles[map.player_pos.y-1][map.player_pos.x-1] = 'P';
18link map_tiles = [['B'] + row + ['B'] for row in map_tiles];
19link map_tiles = [['B' for _ in range(map.level.width + 2)]] + map_tiles + [['B' for _ in range(map.level.width + 2)]];
20link return [''.join(row) for row in map_tiles];
21link}
1linkwith entry {
2link level_manager = LevelManager();
3link for _ in range(2) {
4link (new_level, new_level_map) = level_manager.get_next_level();
5link print(new_level);
6link print('\n'.join(LevelManager.get_map(new_level_map)));
7link }
8link}
This program will now generate two consecutive maps and print them on the terminal. by running this jac file using jac run level_manager.jac
you can simply test your program.
For the sake of this tutorial we have not included the entire development of an actual game. The full game is available on our jac-lang repo. A sample demonstration of the game can be viewed below.