Math is important for AI, but not the way you think
Created: 2025-03-20 22:12:29 | Last Modified: 2025-04-17 09:36:26
I.
So we all know AI is taking over coding. It's obvious, and undeniable. Both the quality and speed of production for AI generated code is currently very high, and it's only getting better. I can make frogger in a one-shot prompt on my GPU, and from what people are saying Claude can basically whip up a spinning hexagon program that cures cancer in under 20 seconds. The way I see it, we are moving from programming languages to natural language as an interface with computers, the same way we moved from assembly language to programming languages.
And honestly, that's great. It's the way of things. Out with the old, in with the new. No problem.
II.
But I do wonder: What will happen to new generations of programmers. From what I hear CS students are basically not programming, and just use chat-gippety for everything. Where is this going to lead us? Now, let me skip right past the old-man-yelling-at-cloud phase of my argument and make a bold and cautiously optimistic prediction: I think higher mathematics will become more important.
III.
I was working on a roguelike example for ghostbox. I thought it would be fun to have a game like nethack or adom that's using AI internally to generate content. Naturally, I was using an LLM to code the map generation part. Here is my prompt:
Below are several files associated with a small roguelike game. As you can see, it uses an entity component system, and is operated kind of like a finite state machine. It uses pygame for graphics.
Create a function mapgen_generic that takes a GameState and a dungeon level as argument. It should do the following:
- Fill the map on the given dungeon level with a random amount of rooms of various sizes.
- Ensure that if there is a level above the current one, the downstairs from that level connect to the upstairs on this level. Place the upstairs accordingly.
- Connect the generated rooms with corridors.
- Place a downstairs, but not in the same room as the upstairs.
- Alter the GameState in-place.
The AI got something like the above plus around 15k tokens of game code. It's a mouthful but that's map generation for ya. Here's what I got back:
def mapgen_generic(game: GameState, dungeon_level: int) -> GameState:
# Constants for room generation
MIN_ROOM_WIDTH = 3
MAX_ROOM_WIDTH = 12
MIN_ROOM_HEIGHT = 3
MAX_ROOM_HEIGHT = 12
MAX_ROOMS = 20
GRID_WIDTH = 70
GRID_HEIGHT = 40
rooms = []
upstairs_placed = False
# Step 1: Check for a downstairs on the level above
if dungeon_level > 0:
for x in range(1, GRID_WIDTH - 1):
for y in range(1, GRID_HEIGHT - 1):
for entity in game.at(x, y, dungeon_level - 1):
if game.get(Downstairs, entity): # Check for Downstairs component
# Place a room with an upstairs at the same coordinates
width = random.randint(MIN_ROOM_WIDTH, MAX_ROOM_WIDTH)
height = random.randint(MIN_ROOM_HEIGHT, MAX_ROOM_HEIGHT)
x = max(1, x - width // 2)
y = max(1, y - height // 2)
x = min(GRID_WIDTH - width - 1, x)
y = min(GRID_HEIGHT - height - 1, y)
new_room = (x, y, width, height)
make_room(game, x, y, dungeon_level, width, height)
make_upstairs(
game, x + width // 2, y + height // 2, dungeon_level
)
rooms.append(new_room)
upstairs_placed = True
break
if upstairs_placed:
break
if upstairs_placed:
break
# Step 2: Generate additional rooms regardless of whether an upstairs was placed
for _ in range(MAX_ROOMS):
width = random.randint(MIN_ROOM_WIDTH, MAX_ROOM_WIDTH)
height = random.randint(MIN_ROOM_HEIGHT, MAX_ROOM_HEIGHT)
x = random.randint(1, GRID_WIDTH - width - 1)
y = random.randint(1, GRID_HEIGHT - height - 1)
new_room = (x, y, width, height)
# Check for intersections with existing rooms
intersects = False
for other_room in rooms:
if (
x < other_room[0] + other_room[2]
and x + width > other_room[0]
and y < other_room[1] + other_room[3]
and y + height > other_room[1]
):
intersects = True
break
if not intersects:
make_room(game, x, y, dungeon_level, width, height)
rooms.append(new_room)
# Step 3: Connect rooms with corridors
for i in range(1, len(rooms)):
(x1, y1, w1, h1) = rooms[i - 1]
(x2, y2, w2, h2) = rooms[i]
# Randomly choose to start horizontal or vertical
if random.choice([True, False]):
# Horizontal then vertical
# Find the edge of the room
corridor_end_x = x1 + w1 // 2
corridor_end_y = y1 + h1 // 2
for x in range(x1 + w1 // 2, x2 + w2 // 2):
if x == corridor_end_x:
make_floor(game, x, corridor_end_y, dungeon_level, name="Corridor", display=",")
else:
make_floor(game, x, corridor_end_y, dungeon_level, name="Corridor", display=",")
corridor_end_x = x2 + w2 // 2
for y in range(y1 + h1 // 2, y2 + h2 // 2):
if y == corridor_end_y:
make_floor(game, corridor_end_x, y, dungeon_level, name="Corridor", display=",")
else:
make_floor(game, corridor_end_x, y, dungeon_level, name="Corridor", display=",")
# Place an open space, open door, or closed door at the end of the corridor
choice = random.choice(['open_space', 'open_door', 'closed_door'])
if choice == 'open_space':
make_floor(game, corridor_end_x, corridor_end_y, dungeon_level, name="Open Space", display=".")
elif choice == 'open_door':
make_door(game, corridor_end_x, corridor_end_y, dungeon_level, closed=False)
elif choice == 'closed_door':
make_door(game, corridor_end_x, corridor_end_y, dungeon_level, closed=True)
else:
# Vertical then horizontal
corridor_end_x = x1 + w1 // 2
corridor_end_y = y1 + h1 // 2
for y in range(y1 + h1 // 2, y2 + h2 // 2):
if y == corridor_end_y:
make_floor(game, corridor_end_x, y, dungeon_level, name="Corridor", display=",")
else:
make_floor(game, corridor_end_x, y, dungeon_level, name="Corridor", display=",")
corridor_end_y = y2 + h2 // 2
for x in range(x1 + w1 // 2, x2 + w2 // 2):
if x == corridor_end_x:
make_floor(game, x, corridor_end_y, dungeon_level, name="Corridor", display=",")
else:
make_floor(game, x, corridor_end_y, dungeon_level, name="Corridor", display=",")
# Place an open space, open door, or closed door at the end of the corridor
choice = random.choice(['open_space', 'open_door', 'closed_door'])
if choice == 'open_space':
make_floor(game, corridor_end_x, corridor_end_y, dungeon_level, name="Open Space", display=".")
elif choice == 'open_door':
make_door(game, corridor_end_x, corridor_end_y, dungeon_level, closed=False)
elif choice == 'closed_door':
make_door(game, corridor_end_x, corridor_end_y, dungeon_level, closed=True)
# Step 4: Place a downstairs in a random room, but not the one with the upstairs
if rooms:
# Exclude the room with the upstairs
available_rooms = [
room
for room in rooms
if not (
room[0] <= x <= room[0] + room[2] and room[1] <= y <= room[1] + room[3]
)
]
if available_rooms:
downstairs_room = random.choice(available_rooms)
(x, y, w, h) = downstairs_room
make_downstairs(game, x + w // 2, y + h // 2, dungeon_level)
return game
So far so good. The AI was so happy to help, because, of course it is. And it looks so promising! It's got steps and everything. I thought I was in love.
But there were problems.
Here's an example of what the above produced:
Yeah. I had the same reaction. It's not even clear where to begin. Somehow it's starting corridors in the middle of rooms, and well, it doesn't quite grasp the idea of rooms having exits, and so on. Sometimes it's helpful to get buggy code from an AI, but in this case I just wanted to kill it with fire and rewrite it from scratch. Even follow up prompts couldn't fix it. It was a true failure, 1-0 for the humans, bad qwen, Bad Qwen.
I decided to revise my approach. Granted, the prompt above was somewhat... messy. Not in the way it's phrased necessarily, but conceptually. If we can do map generation in a really clean way, maybe the AI will be able to deal with it.
So I wrote this:
class MapPrefab(BaseModel):
"""Preliminary representation of a dungeon map."""
# carries x, y, and z (dungeon_level) coordinates as keys
data: Dict[Tuple[int, int, int], MapTilePrefab] = {}
def preview(self) -> str:
"""Gives a string representation of the map as an ASCII image."""
# **FILL THIS IN**
pass
def fill_all_at(self, game: GameState, x: int, y: int, dungeon_level: int) -> None:
"""Fills the given GameState with the internal map of prefab tiles. This will change the given game state, essentially rendering tiles onto the map as entities at the given coordinates.
:param game: The GameState object to modify.
:param x: The x-coordinate of the top left corner of the to be rendered map or feature.
:param y: The y-coordinate of the top left corner of the to be rendered map or feature.
:param dungeon_level: The z-coordinate or dungeon level of the to be rendered map or feature.
"""
for (x1, y1, dungeon_level1), tile_prefab in self.data.items():
# adjust tile offset
offset_tile = MapTilePrefab(
**(
tile_prefab.model_dump()
| {
"x": x + x1,
"y": y + y1,
"dungeon_level": dungeon_level + dungeon_level1,
}
)
)
offset_tile.fill(game)
class MapGenerator(Protocol):
"""Generates areas or volumes of map tiles and dungeon features in a preliminary representation.
Map generators form a monoid under composition (+), with the neutral element being an instance of the MapGeneratorIdentity. Composition isn't guaranteed to commute.
"""
def generate(self) -> MapPrefab:
"""Generates the actual map or dungeon feature."""
pass
def compose(self, other: "MapGenerator") -> "MapGenerator":
"""Compose one map generator with another , yielding a third that will generate a map that is a combination of the maps that would be generated by the composing generators.
Composition does not in general commute."""
pass
# so we can write gen_a + gen_c
def __add__(self, other: "MapGenerator") -> "MapGenerator":
return self.compose(other)
Don't worry about what a MapTilePrefab
is, it's just a tuple with a bunch of flat data like coordinates, name, color, ascii char etc, and it's fill method renders it into the game state in a really tedious way that you want an AI to write. I just left it out for brevity.
The important thing to me was this: In general, things tend to get really powerful when you throw abstract algebra at them. If you can successfully define a notion of composition for something, stuff starts to sort of escalate from there, as lot of other structure follows naturally. In particular, defining a MapGenerator as a monoid means we can create atomic generators and then work our way up, which is real clean. We just need to be able to say how generators compose. This is easy for single tiles, though it may get harder when we're dealing with entire rooms or complex 3D volumes. Still, it's probably worth it. Monoids are just nice. You can print that on a t-shirt.
But there is one more advantage to the monoid approach: AIs speak math. Formulating the problem as something that can be cleanly described by abstract algebraic structures makes it easier for the AI to deal with.
At least so I hoped. Anyway, here was the prompt to go along with it:
Below are several files associated with a small roguelike game. As you can see, it uses an entity component system, and is operated kind of like a finite state machine. It uses pygame for graphics.
Consider roguelike_map.py. I want you to fill in some missing functionality:
- Create the 'fill' method of the MapTilePrefab class. I have marked this in the code.
- Create the 'preview' method of the MapPrefab class. It is marked in the code.
- Create a MapGeneratorIdentity class which serves as the neutral element of generator composition.
- Create a HorizontalCompositionGenerator that classes can inherit from. It should implement the compose method as placing its own and the other generator's map tiles adjacent to each other.
- Create a VerticalCompositionGenerator that can be inherited from. It should compose by placing its own and the other generator's tiles on top of each other.
- Create a RowGenerator that takes a list of other MapGenerators as argument and composes them according to their own composition method, while it itself composes vertically by inheriting from VerticalCompositionGenerator.
- At the bottom of the file, add some basic MapGenerator classes for floor, wall, door, and upstairs/downstairs. You can use the helper functions to implement these. They all should generate only a single tile, and compose Horizontally by default.
- Write a simpleRoomGenerator that puts everything together and generates a room with random width and height, and the following constructor parameters (...)
I edited the prompt a little for brevity, and it wasn't a one-shot, but that was essentially what I asked from the AI. Here's what I got.
class HorizontalComposeGenerator(MapGenerator):
def __init__(self, _generate: Optional[Callable[[], MapPrefab]] = None, **kwargs):
self._kwargs = kwargs
self._generate = _generate
def generate(self) -> MapPrefab:
if self._generate is not None:
return self._generate()
cls = type(self)
return cls.generate(self)
def compose(self, other: "MapGenerator") -> "MapGenerator":
def combined_generate():
map1 = self.generate()
map2 = other.generate()
max_x1 = max(x for (x, y, z) in map1.data.keys())
for (x, y, z), tile in map2.data.items():
map1.data[(x + max_x1 + 1, y, z)] = tile
return map1
cls = type(self)
return cls(_generate=combined_generate, **self._kwargs)
class VerticalComposeGenerator(MapGenerator, ABC):
# **snipped for brevity, the AI made the same as above essentially*
# ...
class RowGenerator(VerticalComposeGenerator):
def __init__(self, generators: List[MapGenerator]):
self.generators = generators
def generate(self) -> MapPrefab:
return reduce(lambda a, b: a + b, self.generators).generate()
class FloorGenerator(HorizontalComposeGenerator):
def __init__(self, **kwargs):
self.kwargs = kwargs
def generate(self) -> MapPrefab:
return MapPrefab(
data={
(0, 0, 0): MapTilePrefab(
x=0,
y=0,
dungeon_level=0,
name="Floor",
# ** ... snipped lots of tedious data**
)
class WallGenerator(HorizontalComposeGenerator):
# ...
class DoorGenerator(HorizontalComposeGenerator):
# ...
class UpstairsGenerator(HorizontalComposeGenerator):
# ...
class DownstairsGenerator(HorizontalComposeGenerator):
# ...
class SimpleRoomGenerator(HorizontalComposeGenerator):
def __init__(
self,
width: int,
height: int,
exit_chance: float = 0.25,
exit_is_door_chance: float = 0.33,
ensure_exit: bool = True,
wall_kwargs: Optional[Dict[str, Any]] = None,
floor_kwargs: Optional[Dict[str, Any]] = None,
):
self.width = width
self.height = height
self.exit_chance = exit_chance
self.exit_is_door_chance = exit_is_door_chance
self.ensure_exit = ensure_exit
self._has_exit = False
self.wall_kwargs = wall_kwargs or {}
self.ensure_exit = ensure_exit
self.floor_kwargs = floor_kwargs or {}
self.default_floor_kwargs = {
k: v
for k, v in FloorGenerator().generate().data[(0, 0, 0)].model_dump().items()
if k not in ["x", "y", "dungeon_level"]
}
self.default_wall_kwargs = {
k: v
for k, v in WallGenerator().generate().data[(0, 0, 0)].model_dump().items()
if k not in ["x", "y", "dungeon_level"]
}
def generate(self) -> MapPrefab:
# Create the room with walls and floors
room_map = MapPrefab(data={})
# Create the floor tiles
for x in range(1, self.width - 1):
for y in range(1, self.height - 1):
room_map.data[(x, y, 0)] = MapTilePrefab(
x=x,
y=y,
dungeon_level=0,
**(self.default_floor_kwargs | self.floor_kwargs),
)
room_map.data[(x, y, 0)].is_in_room = True
# Create the walls
for x in range(self.width):
room_map.data[(x, 0, 0)] = MapTilePrefab(
x=x,
y=0,
dungeon_level=0,
**(self.default_wall_kwargs | self.wall_kwargs),
)
room_map.data[(x, self.height - 1, 0)] = MapTilePrefab(
x=x,
y=self.height - 1,
dungeon_level=0,
**(self.default_wall_kwargs | self.wall_kwargs),
)
for y in range(1, self.height - 1):
room_map.data[(0, y, 0)] = MapTilePrefab(
x=0,
y=y,
dungeon_level=0,
**(self.default_wall_kwargs | self.wall_kwargs),
)
room_map.data[(self.width - 1, y, 0)] = MapTilePrefab(
x=self.width - 1,
y=y,
dungeon_level=0,
**(self.default_wall_kwargs | self.wall_kwargs),
)
# Randomly place exits in the walls
placers = [
# top
lambda: self._place_exits(room_map, [(x, 0) for x in range(1, self.width-1)]),
# bottom
lambda: self._place_exits(room_map, [(x, self.height-1) for x in range(1, self.width-1)]),
# left
lambda: self._place_exits(room_map, [(0, y) for y in range(1, self.height-1)]),
# right
lambda: self._place_exits(room_map, [(self.width-1, y) for y in range(1, self.height-1)])
]
# we will try to generate multiple times
# but i don't want to deal with insane users that make exit_chance = 0.0
# so we have a failsafe
max_trips = 5
trip = 0
while not(self._has_exit):
trip += 1
random.shuffle(placers)
for i in range(len(placers)):
placers[i]()
if not(self.ensure_exit):
break
if trip >= max_trips:
break
return room_map
def _place_exits(
self, room_map: MapPrefab, positions: List[Tuple[int, int]]
) -> None:
forbidden = []
while positions:
if random.random() > self.exit_chance:
# no exit
break
# ok we add an exit
random.shuffle(positions)
x, y = positions.pop()
if random.random() < self.exit_is_door_chance:
# Place a door
room_map.data[(x, y, 0)] = MapTilePrefab(
x=x,
y=y,
dungeon_level=0,
**(
self.default_wall_kwargs
| self.wall_kwargs
| {
"display": "+",
"name": "Door",
"material": Material.Wood,
"solid": True,
}
),
)
else:
# Place a floor tile
room_map.data[(x, y, 0)] = MapTilePrefab(
x=x,
y=y,
dungeon_level=0,
**(self.default_floor_kwargs | self.floor_kwargs),
)
room_map.data[(x, y, 0)].is_exit = True
# ok we added an exit
self._has_exit
# we go back through while loop, but with some positions removed
# because we don't want openings right next to each other
forbidden.append([(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)])
positions = [p for p in positions if p not in forbidden]
Terrific. It's definitely got some AIisms (it didn't use the FloorGenerator etc), but it looks promising. The only thing I added by hand was the code to ensure that an exit is placed (which I hadn't prompted for), and the check to avoid infinite looping, as you can probably tell from the comments about insane users. Let's try it out.
>>> from roguelike_map import *
pygame 2.6.1 (SDL 2.28.4, Python 3.12.7)
Hello from the pygame community. https://www.pygame.org/contribute.html
>>> r = SimpleRoomGenerator(width=5, height=9)
>>> print(r.generate().preview())
#.#.#
#...#
....#
#....
#...#
#....
#...#
#...#
#####
Isn't that a beautiful room. Look at those doorways. Once we can connect these rooms with some corridors, Caves of Qud can go pack it in.
IV.
So if you're under the age of 25 you probably skipped reading the code above. You don't do code anymore. Code is lame. Code is what moms and dads do. Jokes aside, that's honestly ok. I wouldn't read paragraphs of assembler code either. Actually, I probably would, but that's not the point.
The point is that the particular idiosyncrasies of how to write python code, or C++, or rusty or whatever will no longer be important, and it will no longer be required to be fluent in a programming language to create software. That's going away. But, what is not going away, is the ability to think, with clarity, precision, logical consistency, and speed, and then to communicate those thoughts, wether to a human or a machine.
I feel higher mathematics is often looked down upon. Back when I was in school, so like some time after WWII, people were literally giving me this crap about "Oh what will you ever use derivatives in your day job kids should be learning how to save taxes and kickbox yada yada". And when I only so much as mention category theory to my friends, their eyees collectively glaze over. It's a shame, and I think our attitude should change.
Do you think that AI will make coding more accessible to the masses? It seems to be the way things are going. But I wonder if we will see the exact opposite unfold. What if the people who can grasp and wield difficult mathematical and logical concepts effectively are simply able to leverage the productivity gains of AIs more effectively than the unwashed, vibe-coding masses? Will they be left in the dust like the assembler programmers of old who simply wouldn't - or couldn't - move on to those new, fashionable compiler things?
I mean, have you *seen the haskell lens library? This is literal magic. The only thing that held intense nerds who write code like that back was that 99% of people out there won't understand it. Well, literally a 100% of AIs will understand it. So, podner that.