Building a turn-based battle

Play it first → The knight on the right, three monsters on the left, and the four commands every old role-playing game had: Attack, Magic, Item, Run.

It is a proof of concept rather than a whole game — one battle, win or lose. It earns a tutorial because of how it is written.

Nobody here is a game object

Every other game on this site is built out of game objects: things with a position that fall, collide and get destroyed. add() makes one, and the engine then moves it, bumps it into walls and tells you when it touches something.

A battle needs none of that. Nothing moves, nothing falls, and nothing bumps into anything. So there is not a single add() in this game. Every fighter is a plain dictionary, and the screen is drawn from those dictionaries every frame.

This is worth noticing as a habit, not just a trick. The question is not "how do I make this a game object?" but "does this thing need to exist in the world, or do I just need to draw it?" A health bar, a menu, a message — none of those are things in a world.

Step 1 — the fighters are data

A fighter is a dictionary of numbers and a picture. Drawing one is drawSprite, which paints a sprite straight onto the screen without making an object for it.

from kaypy import *

WIDE = 960
TALL = 640

kaypy(width=WIDE, height=TALL, background=[18, 16, 24])

IDLE_RUN = {"idle": {"from": 0, "to": 3, "speed": 8, "loop": True},
            "run": {"from": 4, "to": 7, "speed": 10, "loop": True}}

loadSprite("knight", "dungeon/knight_m.png", sliceX=9, anims=IDLE_RUN)
loadSprite("goblin", "dungeon/goblin.png", sliceX=8, anims=IDLE_RUN)

# A fighter is a dictionary. That is the whole idea.
hero = {
    "name": "Knight",
    "hp": 40, "max_hp": 40,
    "picture": "knight",
    "x": 740, "y": 330,
}

monsters = [
    {"name": "Goblin", "hp": 18, "max_hp": 18,
     "picture": "goblin", "x": 150, "y": 330},
]

INK = rgb(235, 235, 245)
DIM = rgb(150, 150, 170)


@onDraw
def draw_everything():
    for monster in monsters:
        drawSprite(sprite=monster["picture"],
                   pos=vec2(monster["x"], monster["y"]),
                   width=64, height=64, anchor="bot")
        drawText(text=monster["name"],
                 pos=vec2(monster["x"], monster["y"] + 8),
                 size=16, color=DIM, anchor="top")

    drawSprite(sprite=hero["picture"], pos=vec2(hero["x"], hero["y"]),
               width=64, height=112, anchor="bot")
    drawText(text=hero["name"] + "   HP " + str(hero["hp"]),
             pos=vec2(hero["x"], hero["y"] + 8),
             size=16, color=INK, anchor="top")

Run it and you have a goblin facing a knight. To make the goblin tougher, change a number. To add a second monster, copy the block and change the name, the picture and where it stands — which is what the finished game does:

monsters = [
    {"name": "Goblin",   "hp": 18, "max_hp": 18, "attack": 3, "defence": 2,
     "speed": 8, "picture": "goblin",   "x": 150, "y": 330, "friend": False},
    {"name": "Skeleton", "hp": 24, "max_hp": 24, "attack": 4, "defence": 3,
     "speed": 5, "picture": "skeleton", "x": 290, "y": 330, "friend": False},
    {"name": "Chort",    "hp": 30, "max_hp": 30, "attack": 6, "defence": 4,
     "speed": 3, "picture": "chort",    "x": 430, "y": 330, "friend": False},
]

Step 2 — a menu you can move around

The menu is a list of words and a number saying which row you are on. Up and down change the number and wrap around the ends; the drawing puts a bar behind whichever row that is.

from kaypy import *

WIDE = 960
TALL = 640

kaypy(width=WIDE, height=TALL, background=[18, 16, 24])

COMMANDS = ["Attack", "Magic", "Item", "Run"]

cursor = 0
message = "What will you do?"

INK = rgb(235, 235, 245)
GOLD = rgb(240, 200, 90)
BOX = rgb(28, 26, 38)


@onKeyPress("up")
def move_up():
    global cursor
    cursor = cursor - 1
    if cursor < 0:
        cursor = len(COMMANDS) - 1


@onKeyPress("down")
def move_down():
    global cursor
    cursor = cursor + 1
    if cursor > len(COMMANDS) - 1:
        cursor = 0


@onKeyPress("space")
def choose():
    global message
    message = "You chose " + COMMANDS[cursor] + "."


@onDraw
def draw_menu():
    drawRect(pos=vec2(20, 430), width=WIDE - 40, height=54, color=BOX,
             radius=8)
    drawText(text=message, pos=vec2(40, 446), size=20, color=INK)

    drawRect(pos=vec2(20, 496), width=380, height=130, color=BOX, radius=8)
    row = 0
    while row < len(COMMANDS):
        if row == cursor:
            drawRect(pos=vec2(34, 506 + row * 28), width=352, height=26,
                     color=rgb(64, 58, 32), radius=4)
        drawText(text=COMMANDS[row], pos=vec2(66, 510 + row * 28), size=20,
                 color=GOLD if row == cursor else INK)
        row = row + 1

Make the cursor obvious. This drew a small > at first, and in a browser pane that is a few pixels. The game looked like it was ignoring the keyboard. A filled bar behind the row is the whole fix, and it is worth doing before anyone tells you the game is broken.

Step 3 — whose turn is it?

Everyone has a speed. The turn order is just everybody still standing, sorted fastest first — one list, rebuilt each round. That is also how a monster that has just been defeated stops getting turns, without any special case for it.

def build_order():
    """Everyone still standing, fastest first."""
    global order, turn

    fighters = []
    if hero["hp"] > 0:
        fighters.append(hero)
    for monster in living_monsters():
        fighters.append(monster)

    # Sort by speed, highest first. sorted() leaves the original list alone
    # and hands back a new one in the order we asked for.
    order = sorted(fighters, key=speed_of, reverse=True)
    turn = 0

sorted() hands back a new list in the order you asked for and leaves the original alone. reverse=True puts the biggest speed first.

Step 4 — doing something

Damage is attack minus defence, give or take a little, and never less than one — so a strong defence makes you hard to hurt but never invincible:

def damage_from(attacker, defender):
    """Attack minus defence, give or take a little, never less than one."""
    rough = attacker["attack"] + randi(0, 3) - defender["defence"]
    if rough < 1:
        rough = 1
    return rough

Every action ends by putting a line on the message bar and pausing for a moment. That pause is the only thing in the whole game that happens over time:

@onUpdate
def each_frame():
    """The only thing that happens over time: waiting on a message."""
    global pause_left, doing, keep_turn

    if doing != "watching":
        return

    pause_left = pause_left - dt()
    if pause_left > 0:
        return

    # The message has had its moment.
    if battle_finished():
        return
    if keep_turn:
        doing = "choosing"      # it was only an explanation
        return
    next_turn()

Two things that went wrong

The battle never ended

Winning called say_and_wait("You win!") — and that function set the game's state to "watching a message", after the code had just set it to "over". So the end state was wiped every time, and the battle never actually finished.

It looked completely fine, because the message sits there for ninety-nine seconds. Nobody playing it would ever notice. It would have broken the moment anything came after a battle — a reward screen, a next fight, anything.

A refusal cost you your turn

Choosing a spell you could not afford printed "Not enough MP" and then passed the turn to the monsters. Being told no is not a turn. The fix is that a message can say which kind it is:

def say_and_wait(words, seconds=0.9, yours_again=False, ends_it=False):
    """Put a line on the message bar and hold there for a moment.

    yours_again=True means the message did not use up a turn -- it was a
    refusal, like trying to cast a spell you cannot afford.

    ends_it=True means this is the last thing that will happen.

    THE ORDER MATTERS HERE. This used to be called AFTER the caller had set
    doing = "over", and then quietly set doing = "watching" itself, so the
    battle never actually reached its end state. It still looked finished,
    because the message sat there for ninety-nine seconds -- which is exactly
    the kind of bug that survives being played.
    """
    global message, pause_left, doing, keep_turn

    message = words
    pause_left = seconds
    doing = "over" if ends_it else "watching"
    keep_turn = yours_again

Make it yours

Open the whole game in the playground → It goes straight into the editor, ready to change and run.

  • Add a fourth monster. Copy a block in monsters, change the name and picture, and give it an x that does not overlap the others.
  • Add a spell to spells. A cheap one that always does the same damage, or an expensive one that hits everything — that last one needs a loop over living_monsters().
  • Give the monsters a brain. Right now every monster attacks. Make the weakest one drink a potion when it is nearly finished.
  • Give the knight a friend. That is the big one: a second fighter with "friend": True, which means the turn order already handles it and you need somewhere to put a second status box.

Open the playground →