Skip to content
Fondazione ITS INCOM

Examples · AR/VR & Game Developer

A level you can finish

gpt-oss-120b draws a dungeon level as rows of text, from a description. The schema fixes its size and its tiles, and a short Python checker proves that it can be finished: the key before the door, and the door before the exit. Then a script builds it in Unity.

Advanced · 60 min

The idea

A level described in words is easy to write: “the key is in the left half, behind a locked door”. A level drawn by a model can break that sentence without anyone noticing: the key behind the door it opens, a gap in the wall that makes the door useless, an exit that no path reaches. The player finds out after an hour.

A level drawn as text can be checked by a program, the way a player would try it:

  1. the model draws the level, one row of text for each row of tiles;
  2. the schema forces the size and the tiles, so every map can be read;
  3. your checker looks for the way through, and draws it on the map;
  4. a level that fails is thrown away, and the model draws a new one.

What you need

A key and the openai library, as in Your first API call in Python. For the last part, Unity.

The model

gpt-oss-120b (gpt-oss-120b), the model that reasons before it answers. Drawing a map means keeping track of where every wall is, and in our tests the other models could not:

Model Levels Passed every check
gpt-oss-120b 16 15
gemma-4-26b 3 0
qwen3.6-35b 3 0
qwen3-coder-30b 3 0

The others drew doors beside the wall instead of in it, exits in the outer wall, dividing walls with gaps or none at all. Reasoning has a price: a level takes about a minute, against a few seconds.

The tiles

Tile
# wall
. floor
S where the player starts
E the exit
K the key
D the locked door: it opens with the key
^ spikes: the player must go around them

The checker

These are the rules of your game, written as code. Save it as check_level.py:

import json
import sys
from collections import deque

WIDTH, HEIGHT = 20, 10


def find(rows, tile):
    return [(x, y) for y, row in enumerate(rows) for x, t in enumerate(row) if t == tile]


def route(rows, start, goal, blocked):
    """The shortest way from start to goal, as a list of cells, or None. Tiles in blocked cannot be crossed."""
    came_from, todo = {start: None}, deque([start])
    while todo:
        cell = todo.popleft()
        if cell == goal:
            path = []
            while cell:
                path.append(cell)
                cell = came_from[cell]
            return path[::-1]
        x, y = cell
        for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
            if 0 <= nx < WIDTH and 0 <= ny < HEIGHT and (nx, ny) not in came_from and rows[ny][nx] not in blocked:
                came_from[(nx, ny)] = cell
                todo.append((nx, ny))
    return None


def problems(rows):
    """The rules of the level. Returns the problems, and the player's way through when there are none."""
    found = []
    for tile, name in (("S", "start"), ("E", "exit"), ("K", "key")):
        if len(find(rows, tile)) != 1:
            found.append(f"there must be one {name} ({tile}), there are {len(find(rows, tile))}")
    if not find(rows, "D"):
        found.append("there is no door (D)")
    if len(find(rows, "^")) != 3:
        found.append(f"there must be 3 spikes (^), there are {len(find(rows, '^'))}")
    edge = rows[0] + rows[-1] + "".join(row[0] + row[-1] for row in rows)
    if set(edge) != {"#"}:
        found.append("the edge of the map is not all wall")
    if any(len(find(rows, tile)) != 1 for tile in "SEK"):
        return found, None  # without one start, one exit and one key there is no way to follow

    start, exit_, key = find(rows, "S")[0], find(rows, "E")[0], find(rows, "K")[0]
    to_key = route(rows, start, key, "#^D")  # before the key: walls, spikes and the locked door stop you
    to_exit = route(rows, key, exit_, "#^")  # with the key, the door opens
    if not to_key:
        found.append("the key cannot be reached without going through the door")
    if route(rows, start, exit_, "#^D"):
        found.append("the exit can be reached without the key: the wall has a gap")
    if not to_exit:
        found.append("the exit cannot be reached, not even with the key")
    return found, (to_key + to_exit if not found else None)


def show(rows, path=None):
    """The map, with the player's way through drawn as *."""
    grid = [list(row) for row in rows]
    for x, y in path or []:
        if grid[y][x] == ".":
            grid[y][x] = "*"
    print("\n".join("".join(row) for row in grid))


if __name__ == "__main__":
    with open(sys.argv[1] if len(sys.argv) > 1 else "level.json") as f:
        rows = json.load(f)["rows"]
    found, path = problems(rows)
    show(rows, path)
    for p in found:
        print("-", p)
    print("problems:", len(found))

route is a breadth-first search: from the start, it tries every neighbouring tile, then their neighbours, and so on, until it reaches the goal or runs out of tiles. Tiles in blocked stop it. Three searches tell the whole story: to the key with the door closed, to the exit with the door closed (it must fail), to the exit with the key.

The generator

Save it as make_level.py, in the same folder:

import json
import os

from openai import OpenAI

from check_level import HEIGHT, WIDTH, problems, show

client = OpenAI(
    base_url="https://api.ai.itsincom.org/v1",
    api_key=os.environ["API_KEY"],
)

schema = {
    "type": "object",
    "properties": {
        "rows": {
            "type": "array",
            "minItems": HEIGHT,
            "maxItems": HEIGHT,
            # Every row has exactly WIDTH tiles, and only the tiles of the game
            "items": {"type": "string", "pattern": f"^[#.SEKD^]{{{WIDTH}}}$"},
        },
    },
    "required": ["rows"],
}

messages = [
    {"role": "system", "content": "You design levels for a top-down 2D dungeon game, as rows of text. Tiles: "
                                  "# wall, . floor, S start, E exit, K key, D locked door, ^ spikes."},
    {"role": "user", "content": f"A level {WIDTH} tiles wide and {HEIGHT} tall, with walls all around the edge. "
                                "A wall from top to bottom splits it in two halves, and a locked door D in that "
                                "wall is the only way through. The player starts at S in the left half; the exit "
                                "E is in the right half. The key K is in the left half, at the end of a short "
                                "corridor. Three spikes ^ in the right half, near the way to the exit but not "
                                "blocking it."},
]

for attempt in range(1, 4):
    answer = client.chat.completions.create(
        model="gpt-oss-120b",
        temperature=0.8,
        messages=messages,
        response_format={"type": "json_schema", "json_schema": {"name": "level", "schema": schema}},
    )
    rows = json.loads(answer.choices[0].message.content)["rows"]
    found, path = problems(rows)
    print(f"attempt {attempt}: problems: {len(found)}")
    show(rows, path)
    for p in found:
        print("  -", p)
    if not found:
        with open("level.json", "w") as f:
            json.dump({"rows": rows}, f, indent=2)
        print("Saved in level.json")
        break
    # A new level, not a repaired one: a map is easier to draw again than to mend.
else:
    print("No level passed in three attempts: read the problems, and change the request.")

The pattern in the schema is a regular expression that every row must match: exactly 20 tiles, each one from the list. The school's machines force the answer to follow it, so the model cannot write a row of 19 tiles, or a letter that is not a tile. The schema decides the shape; the checker decides whether the level works.

Run it with python3 make_level.py. Each attempt takes about a minute: the model reasons first, and you see nothing until the map arrives.

What you should see

attempt 1: problems: 0
####################
#.........#........#
#.S***....#........#
#....*#####........#
#....***K##........#
#.......**D**......#
#.........#.*^^^...#
#.........#.***E...#
#.........#........#
####################
Saved in level.json

The * are the way through, found by the checker: from S to the key, then through the door, and on to the exit, around the spikes. In each of our last four runs, the first level passed.

Read the map

The checks passed: the level can be finished. Now compare it with the request: “The key K is in the left half, at the end of a short corridor.” In this level the key sits in a corner, with walls on two sides: the closest our levels came to a corridor, and the exception. Of our fifteen levels that passed, this was the only one with walls on two sides of the key: in the others the key lay in the open, in an empty room.

The checker did not notice, because nobody wrote that rule. Whether the corridor is there, whether the spikes are on the way or in a corner nobody visits, whether the level is any fun: you see those on the map, or by playing it. Then, if a rule matters, write it in problems(), and the next levels will be checked for it.

In Unity

Put level.json in Assets. This script builds the level from seven prefabs, one for each tile, as children of the object it sits on. Save it as LevelBuilder.cs:

using UnityEngine;

[System.Serializable]
public class Level { public string[] rows; }

public class LevelBuilder : MonoBehaviour
{
    public TextAsset levelFile;  // level.json, dragged here in the Inspector
    public GameObject wall, floor, start, exit, key, door, spikes;  // one prefab for each tile

    void Start()
    {
        string[] rows = JsonUtility.FromJson<Level>(levelFile.text).rows;
        for (int y = 0; y < rows.Length; y++)
        {
            for (int x = 0; x < rows[y].Length; x++)
            {
                // Row y of the file goes to z = -y: seen from above, the first row is at the top, as in the text.
                Vector3 position = new Vector3(x, 0, -y);
                char tile = rows[y][x];
                Instantiate(tile == '#' ? wall : floor, position, Quaternion.identity, transform);
                GameObject thing = tile switch
                {
                    'S' => start, 'E' => exit, 'K' => key, 'D' => door, '^' => spikes, _ => null,
                };
                if (thing != null) Instantiate(thing, position, Quaternion.identity, transform);
            }
        }
    }
}

Add the script to an empty object, drag level.json into Level File and a prefab into each tile's field (cubes of different colours are enough to start), and press Play. Every tile is 1 unit wide, and the level lies on the ground: seen from above, it looks like the text, with the first row at the top. We have not run it inside Unity: we compiled it with .NET, with small stand-ins for Unity's classes, and on our level.json it placed 68 walls, 132 floor tiles, 3 spikes and one each of start, key, door and exit.

Good to know

Exercise

All examples