Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Enemies that think

A bad guy that walks straight at you gets stuck on the first wall. One that finds its way round, notices when it can see you, and wanders about when it can’t, feels alive. I have a set of functions for exactly that, and none of them needs more than a line.

Thinking on a grid

Most of them think about the level as a grid: a list of rows, where 0 is a square you can walk on and anything else is in the way. That’s the same shape as the grid of a tile map, and they read it the same way, grid(y, x), a row and then a column. A place on the grid is a coordinate (x, y): a column and then a row.

maze makes one for you, walled all round. Give it a seed and you get the same maze every time:

SCREEN 320, 240
grid = maze(15, 7, 42)
FOR y = 0 ... 6
    row = ""
    FOR x = 0 ... 14
        IF grid(y, x) = 0 THEN row = row + "." ELSE row = row + "#"
    END FOR
    PRINT row
END FOR
###############
#.............#
#.#.#########.#
#.#.#...#.#...#
#.###.###.#.###
#.....#.......#
###############

Without a seed it’s a different maze each time. After the seed it can take a style: "BACKTRACKER", long winding corridors and the one it starts with; "PRIM", lots of short dead ends; "DIVISION", straight walls like rooms; or "CAVE", a cave. And a last number from 0 to 1 knocks out that share of the dead ends, so there’s more than one way round: maze(21, 15, 7, "PRIM", 0.3).

Finding the way

astar finds the shortest path across a grid from one square to another, round anything in the way. It gives back the squares to walk through, from the start to the end, or [] when there’s no way there at all.

SCREEN 320, 240
// A wall down column 3, with a gap at the bottom.
grid = [[0, 0, 0, 1, 0, 0],
        [0, 0, 0, 1, 0, 0],
        [0, 0, 0, 1, 0, 0],
        [0, 0, 0, 0, 0, 0]]
path = astar(grid, (0, 0), (5, 0))
PRINT path
PRINT "the first step is to", path(1)
PRINT "into the wall:", astar(grid, (0, 0), (3, 0))
[(0, 0), (1, 1), (2, 2), (3, 3), (4, 2), (5, 1), (5, 0)]
the first step is to (1, 1)
into the wall: []

It goes diagonally as well as straight, so the path cuts across to the gap and back up. An enemy only needs path(1), the next square, and asks again later, when you’ve moved.

Crash-tan’s Tip: astar is quick, but there’s no need to ask it every tick. An enemy that looks again each time it takes a step is every bit as clever, and leaves your loop more time for everything else.

Two more look at the grid around a square. neighbors gives the squares next to one that you can step on (up, down, left and right), and floodfill counts every square you can reach from one, which tells you whether a level you’ve made has bits nobody can get to:

SCREEN 320, 240
grid = [[0, 0, 1, 0, 0],
        [0, 0, 1, 0, 0],
        [0, 0, 1, 0, 0]]
PRINT "next to (1, 1):", neighbors(grid, (1, 1))
PRINT "reachable from the left:", floodfill(grid, (0, 0), 0)
PRINT "reachable from the right:", floodfill(grid, (4, 0), 0)
next to (1, 1): [(1, 0), (1, 2), (0, 1)]
reachable from the left: 6
reachable from the right: 6

Can it see you?

raycast draws a straight line between two squares and says whether anything is in the way: 1 if the line is clear, 0 if it isn’t. That’s line of sight. A guard that can see you comes running, and one that can’t keeps to its rounds.

SCREEN 320, 240
grid = [[0, 0, 0, 0, 0],
        [0, 0, 1, 0, 0],
        [0, 0, 0, 0, 0]]
PRINT "along the top:", raycast(grid, (0, 0), (4, 0))
PRINT "through the pillar:", raycast(grid, (0, 1), (4, 1))
along the top: 1
through the pillar: 0

Moving about

Not everything lives on a grid. For things that move freely, these work in pixels, and give back a velocity: how far to move this tick, across and down.

  • seek(from, to, speed) heads straight for something.
  • flee(from, away_from, speed) heads straight away from it.
  • wander(place, heading, radius, distance) gives a new place a little way ahead, wobbling at random: a sheep that’s grazing.
  • distance(a, b) is how far apart two places are, and nearest(place, places) is which of a list of places is closest, as its index.
SCREEN 320, 240
v = seek((0, 0), (30, 40), 5)
PRINT "toward:", v, "  away:", flee((0, 0), (30, 40), 5)
PRINT "how far:", distance((0, 0), (30, 40))
PRINT "nearest:", nearest((0, 0), [(50, 50), (5, 5), (90, 0)])
toward: (3, 4)   away: (-3, -4)
how far: 50.0
nearest: 1

Here are three of them at once, around the mouse: a chick that follows it, a mouse-shy sheep that runs off when it comes close, and one more sheep that just wanders.

SCREEN 320, 240
MEMORYBUFFER "chick", 10, 10
    CIRCLE (5, 5), 4, 14, 14
END MEMORYBUFFER
MEMORYBUFFER "sheep", 14, 10
    CIRCLE (7, 5), 6, 15, 15
END MEMORYBUFFER

chick = (60, 120)
shy = (160, 120)
grazer = (260, 120)
heading = 0
FOR n = 0 ... 2
    ENABLE SPRITE n
NEXT
SET SPRITE 0 !! image("chick") x(chick.x) y(chick.y) origin((0.5, 0.5))
SET SPRITE 1 !! image("sheep") x(shy.x) y(shy.y) origin((0.5, 0.5))
SET SPRITE 2 !! image("sheep") x(grazer.x) y(grazer.y) origin((0.5, 0.5))
FOR n = 0 ... 2
    SHOW SPRITE n
NEXT

DO
    IF COOLDOWN(1)
        mouse = (mousex, mousey)
        IF distance(chick, mouse) > 12 THEN chick = chick + seek(chick, mouse, 1.5)
        IF distance(shy, mouse) < 60 THEN shy = shy + flee(shy, mouse, 2)
        heading = heading + (rnd - 0.5) * 20
        grazer = wander(grazer, heading, 0.3, 0.5)
        shy = (clamp(shy.x, 0, 320), clamp(shy.y, 0, 240))
        grazer = (clamp(grazer.x, 0, 320), clamp(grazer.y, 0, 240))
        SET SPRITE 0 !! x(chick.x) y(chick.y)
        SET SPRITE 1 !! x(shy.x) y(shy.y)
        SET SPRITE 2 !! x(grazer.x) y(grazer.y)
    END IF
LOOP

A little luck

An enemy that does the same thing every time is easy to beat. choose picks one thing from a list, each as likely as the next, and weighted picks with odds: the weights [6, 3, 1] make the first thing six times as likely as the last. Press Space to open a chest:

SCREEN 320, 240
loot = ["a coin", "a potion", "a sword"]
held = FALSE
DO
    IF COOLDOWN(1)
        IF keydown("Space") AND NOT held
            found = weighted(loot, [6, 3, 1])
            mood = choose(["Ooh,", "Hey,", "Nice,"])
            PRINT mood, found + "!"
        END IF
        held = keydown("Space")
    END IF
LOOP

The chaser

Here’s an enemy that thinks. The level is a maze with a few loops in it, so there’s somewhere to run. The hero moves a square at a time with the arrow keys. The chaser asks astar for the way to the hero and takes the next step. When raycast says it can see the hero, it goes red and speeds up.

SCREEN 320, 240
RANDOMIZE
LOADIMG "hero.png", "hero"

// The level: a maze, 20 squares by 15, drawn as a tile map.
grid = maze(20, 15, 0, "BACKTRACKER", 0.4)
MEMORYBUFFER "tiles", 16, 16
    LINE (0, 0) -> (15, 15), 8 !! box fill
    LINE (1, 1) -> (14, 7), 7 !! box
END MEMORYBUFFER
TILEMAP "maze"
    TILESET "tiles"
    TILESIZE 16
    SIZE 20, 15
    TILES 0 !! from(grid)
END TILEMAP
CREATE MAP "maze" !! layer_to(0)

MEMORYBUFFER "calm", 16, 16
    CIRCLE (8, 8), 6, 9, 9
END MEMORYBUFFER
MEMORYBUFFER "angry", 16, 16
    CIRCLE (8, 8), 7, 12, 12
END MEMORYBUFFER
TOSPRITESHEET "chaser", ["calm", "angry"]

// The hero starts at the top left, the chaser at the bottom right.
her = (1, 1)
chaser = (17, 13)
hero = reserve_sprite()
SET SPRITE hero !! image("hero") x(her.x * 16 + 8) y(her.y * 16 + 8)
    !! origin((0.5, 0.5)) scale((0.5, 0.5))
SHOW SPRITE hero
enemy = reserve_sprite()
SET SPRITE enemy !! image("chaser") frame(0) x(chaser.x * 16) y(chaser.y * 16)
SHOW SPRITE enemy

FUNCTION walkable(place)
    RETURN grid(place.y, place.x) = 0
END FUNCTION

DO WHILE her <> chaser
    // The hero: a square every 8 ticks while an arrow is held.
    IF COOLDOWN(8)
        target = her
        IF keydown("Left") THEN target = (her.x - 1, her.y)
        IF keydown("Right") THEN target = (her.x + 1, her.y)
        IF keydown("Up") THEN target = (her.x, her.y - 1)
        IF keydown("Down") THEN target = (her.x, her.y + 1)
        IF walkable(target) THEN her = target
        SET SPRITE hero !! x(her.x * 16 + 8) y(her.y * 16 + 8)
    END IF

    // The chaser: a square every 6 ticks if it can see the hero, every 14 if not.
    sees = raycast(grid, chaser, her)
    IF sees
        SET SPRITE enemy !! frame(1)
    ELSE
        SET SPRITE enemy !! frame(0)
    END IF
    IF (sees AND COOLDOWN(6)) OR (NOT sees AND COOLDOWN(14))
        path = astar(grid, chaser, her)
        IF len(path) > 1 THEN chaser = path(1)
        SET SPRITE enemy !! x(chaser.x * 16) y(chaser.y * 16)
    END IF
LOOP
SOUND 110, 0.5

Each COOLDOWN keeps its own time, so the two speeds don’t trip over each other. Make the chaser smarter or slower, give it a friend, or make it wander until it first sees you.