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 anxthat 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 overliving_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.