Building Block Slide
Play it first → Drag a block left or right, with your finger on a phone or the mouse on a computer. Blocks with nothing under them fall, and a row with no gaps disappears. After every move a new row pushes up from the bottom, and if the blocks are pushed off the top, the game is over.
This is how it was built, a step at a time. Every numbered step below is a whole program: copy it into the playground, press Run, and it works. Each step is the one before it with something added, so you can build the game the way it was built. Then break something on purpose and see what changes. That teaches you more than reading does.
There is nothing clever in it. No list comprehensions, no tricks, and every kaypy function it uses is on the API page.
The one big idea: the board is a list
The other games on this site are made of game objects: things
added with add() that move and bump into each other by
themselves. This one has none. Every block is a dictionary:
{"row": 9, "col": 2, "length": 3, "x": 120, "y": 560}
and the whole board is a list of them. Row 0 is the top and row 9 the bottom; column 0 is the left. A block of length 3 at column 2 covers columns 2, 3 and 4. Every frame the board is drawn fresh from the list, so moving a block is just changing a number in its dictionary.
That makes the rules easy to write. "Can this block fall?" is a loop over the squares underneath it. "Is this row full?" is a loop along it. No physics, no collisions: just lists, dictionaries and loops.
Step 1 — the board, drawn from a list
from kaypy import *
COLS = 8 # squares across
ROWS = 10 # squares down
CELL = 50 # one square is 50 pixels
BOARD_X = 20 # where the board's top-left corner is in the window
BOARD_Y = 110
# Tall rather than wide, to fit a phone held upright.
kaypy(width=COLS * CELL + 40, height=BOARD_Y + ROWS * CELL + 30,
background=[30, 30, 50])
# A block's colour depends on how long it is.
COLOURS = {
1: (240, 90, 90),
2: (250, 180, 60),
3: (90, 200, 120),
4: (80, 150, 240),
}
def cell_x(col):
return BOARD_X + col * CELL
def cell_y(row):
return BOARD_Y + row * CELL
def new_block(row, col, length):
return {"row": row, "col": col, "length": length,
"x": cell_x(col), "y": cell_y(row)}
# Every block is a dictionary in this list. Change a number and run it
# again: the picture follows.
blocks = [
new_block(9, 0, 3),
new_block(9, 4, 4),
new_block(8, 1, 2),
new_block(8, 5, 1),
new_block(7, 2, 4),
]
@onDraw
def draw_everything():
# The board, and a faint square for every space.
drawRect(pos=vec2(BOARD_X - 6, BOARD_Y - 6), width=COLS * CELL + 12,
height=ROWS * CELL + 12, color=(50, 50, 80), radius=8)
for row in range(ROWS):
for col in range(COLS):
drawRect(pos=vec2(cell_x(col) + 2, cell_y(row) + 2),
width=CELL - 4, height=CELL - 4, color=(40, 40, 64),
radius=6)
for block in blocks:
colour = COLOURS[block["length"]]
drawRect(pos=vec2(block["x"] + 3, block["y"] + 3),
width=block["length"] * CELL - 6, height=CELL - 6,
color=colour, radius=10)
Run it and you get the board with five blocks on it. Nothing moves yet.
Change a number in one of the new_block(...) lines and run it
again: the picture follows the list.
cell_x(col)andcell_y(row)turn a square into pixels: the board's corner, plus that many squares ofCELLpixels. Everything that draws uses them, so the board can move or change size by changing one number.new_block(row, col, length)makes the dictionary for a block, so the list can be written as one short line per block.@onDrawruns every frame, after everything else, and is the only place thedraw...functions work. It draws the board, then one rounded rectangle per block.COLOURS[block["length"]]looks up the colour for that length in a dictionary.
Put two blocks in the same square and there's no error message. One just gets drawn on top of the other. Nothing checks the list but you, until later steps start asking questions about it.
Step 2 — random rows
from kaypy import *
COLS = 8 # squares across
ROWS = 10 # squares down
CELL = 50 # one square is 50 pixels
BOARD_X = 20 # where the board's top-left corner is in the window
BOARD_Y = 110
# Tall rather than wide, to fit a phone held upright.
kaypy(width=COLS * CELL + 40, height=BOARD_Y + ROWS * CELL + 30,
background=[30, 30, 50])
START_ROWS = 4 # rows of blocks on the board when a game starts
GAP_CHANCE = 0.3 # how often a new row has a gap where a block could be
# A block's colour depends on how long it is.
COLOURS = {
1: (240, 90, 90),
2: (250, 180, 60),
3: (90, 200, 120),
4: (80, 150, 240),
}
def cell_x(col):
return BOARD_X + col * CELL
def cell_y(row):
return BOARD_Y + row * CELL
def new_block(row, col, length):
return {"row": row, "col": col, "length": length,
"x": cell_x(col), "y": cell_y(row)}
blocks = []
def make_row(row):
made = []
col = 0
while col < COLS:
if chance(GAP_CHANCE):
col = col + 1
else:
length = choose([1, 2, 3, 4])
if col + length > COLS:
length = COLS - col
made.append(new_block(row, col, length))
col = col + length
filled = 0
for block in made:
filled = filled + block["length"]
if filled == COLS:
made.pop()
return made
# Fill the bottom rows to start with. Every game is different.
for row in range(ROWS - START_ROWS, ROWS):
for block in make_row(row):
blocks.append(block)
@onDraw
def draw_everything():
drawRect(pos=vec2(BOARD_X - 6, BOARD_Y - 6), width=COLS * CELL + 12,
height=ROWS * CELL + 12, color=(50, 50, 80), radius=8)
for row in range(ROWS):
for col in range(COLS):
drawRect(pos=vec2(cell_x(col) + 2, cell_y(row) + 2),
width=CELL - 4, height=CELL - 4, color=(40, 40, 64),
radius=6)
for block in blocks:
colour = COLOURS[block["length"]]
drawRect(pos=vec2(block["x"] + 3, block["y"] + 3),
width=block["length"] * CELL - 6, height=CELL - 6,
color=colour, radius=10)
Run it a few times. The bottom four rows are different every time.
make_row(row) walks along the row from the left. At each
square it either leaves a gap (chance(GAP_CHANCE) is true about
3 times in 10) or puts down a block of a random length, cut short if it would
run off the edge. Either way, col moves past what it just did,
and the loop stops at the end of the row.
A row with no gaps at all would be cleared the moment it appeared, which
isn't fair. So make_row adds up the lengths, and if they come to
the whole width, it takes the last block away again with
made.pop().
Step 3 — dragging
from kaypy import *
COLS = 8 # squares across
ROWS = 10 # squares down
CELL = 50 # one square is 50 pixels
BOARD_X = 20 # where the board's top-left corner is in the window
BOARD_Y = 110
# Tall rather than wide, to fit a phone held upright.
kaypy(width=COLS * CELL + 40, height=BOARD_Y + ROWS * CELL + 30,
background=[30, 30, 50])
START_ROWS = 4 # rows of blocks on the board when a game starts
GAP_CHANCE = 0.3 # how often a new row has a gap where a block could be
# A block's colour depends on how long it is.
COLOURS = {
1: (240, 90, 90),
2: (250, 180, 60),
3: (90, 200, 120),
4: (80, 150, 240),
}
def cell_x(col):
return BOARD_X + col * CELL
def cell_y(row):
return BOARD_Y + row * CELL
def new_block(row, col, length):
return {"row": row, "col": col, "length": length,
"x": cell_x(col), "y": cell_y(row)}
blocks = []
held = None # the block being dragged, or None
grab_dx = 0 # where on the block the finger is, in pixels
left_limit = 0 # how far the held block may go, in pixels
right_limit = 0
def block_at(row, col):
"""The block covering that square, or None if it is empty."""
for block in blocks:
if block["row"] == row:
if block["col"] <= col < block["col"] + block["length"]:
return block
return None
def make_row(row):
made = []
col = 0
while col < COLS:
if chance(GAP_CHANCE):
col = col + 1
else:
length = choose([1, 2, 3, 4])
if col + length > COLS:
length = COLS - col
made.append(new_block(row, col, length))
col = col + length
filled = 0
for block in made:
filled = filled + block["length"]
if filled == COLS:
made.pop()
return made
# Fill the bottom rows to start with. Every game is different.
for row in range(ROWS - START_ROWS, ROWS):
for block in make_row(row):
blocks.append(block)
@onMousePress
def grab():
global held, grab_dx, left_limit, right_limit
finger = mousePos()
col = int((finger.x - BOARD_X) // CELL)
row = int((finger.y - BOARD_Y) // CELL)
if col < 0 or col >= COLS or row < 0 or row >= ROWS:
return
block = block_at(row, col)
if block is None:
return
held = block
grab_dx = finger.x - block["x"]
# How far it can go: walk left from the block until a square is taken
# or the board ends, then the same to the right.
left = block["col"]
while left > 0 and block_at(row, left - 1) is None:
left = left - 1
right = block["col"]
while (right + block["length"] < COLS
and block_at(row, right + block["length"]) is None):
right = right + 1
left_limit = cell_x(left)
right_limit = cell_x(right)
@onMouseRelease
def let_go():
global held
if held is None:
return
# Snap to the nearest column.
held["col"] = round((held["x"] - BOARD_X) / CELL)
held = None
@onUpdate
def glide():
if held is not None:
held["x"] = clamp(mousePos().x - grab_dx, left_limit, right_limit)
# Every other block slides to its column, a good part of the way
# each frame.
amount = clamp(dt() * 18, 0, 1)
for block in blocks:
if block is not held:
block["x"] = lerp(block["x"], cell_x(block["col"]), amount)
@onDraw
def draw_everything():
drawRect(pos=vec2(BOARD_X - 6, BOARD_Y - 6), width=COLS * CELL + 12,
height=ROWS * CELL + 12, color=(50, 50, 80), radius=8)
for row in range(ROWS):
for col in range(COLS):
drawRect(pos=vec2(cell_x(col) + 2, cell_y(row) + 2),
width=CELL - 4, height=CELL - 4, color=(40, 40, 64),
radius=6)
for block in blocks:
colour = COLOURS[block["length"]]
drawRect(pos=vec2(block["x"] + 3, block["y"] + 3),
width=block["length"] * CELL - 6, height=CELL - 6,
color=colour, radius=10)
Drag a block sideways and it slides, as far as the gaps let it, and snaps to a column when you let go. Nothing falls yet, so a block dragged off another one is left floating in mid-air. That's step 4.
A finger is a mouse. On a phone, a finger touching the
screen comes into the game as onMousePress, moving it shows in
mousePos(), and lifting it is onMouseRelease. One
set of code works for both, which is why this game needs nothing special for
touch.
Which block? grab turns the finger's position
into a square. Take away the board's corner and divide by the size of a
square: // divides and throws away the remainder, so anywhere
in the first 50 pixels is square 0. block_at(row, col) then
looks through the list for the block covering that square.
How far can it go? Before the block moves at all,
grab walks left from it, one square at a time, until a square is
taken or the board ends, and the same to the right. Those two limits, in
pixels, are as far as it can be dragged. clamp keeps it between
them.
Where you held it. grab_dx is how far along
the block your finger is. Without it, the block's left end would jump under
your finger the moment you touched it; with it, the block keeps the same
part under your finger all the way.
Gliding. While you drag, the block's "x"
follows your finger exactly. When you let go, "col" is set to the
nearest column, and every frame lerp moves "x" a
good part of the way there, so the block settles into place instead of
jumping.
Step 4 — falling and clearing
Let go of a block and the turn plays out: anything with nothing under it falls, a full row flashes white and goes, and everything above it falls too.
from kaypy import *
COLS = 8 # squares across
ROWS = 10 # squares down
CELL = 50 # one square is 50 pixels
BOARD_X = 20 # where the board's top-left corner is in the window
BOARD_Y = 110
# Tall rather than wide, to fit a phone held upright.
kaypy(width=COLS * CELL + 40, height=BOARD_Y + ROWS * CELL + 30,
background=[30, 30, 50])
START_ROWS = 4 # rows of blocks on the board when a game starts
GAP_CHANCE = 0.3 # how often a new row has a gap where a block could be
STEP_TIME = 0.08 # seconds between one step of falling and the next
CLEAR_TIME = 0.25 # how long a full row flashes before it goes
POINTS_PER_ROW = 10
# A block's colour depends on how long it is.
COLOURS = {
1: (240, 90, 90),
2: (250, 180, 60),
3: (90, 200, 120),
4: (80, 150, 240),
}
blocks = []
def cell_x(col):
return BOARD_X + col * CELL
def cell_y(row):
return BOARD_Y + row * CELL
def new_block(row, col, length):
return {"row": row, "col": col, "length": length,
"x": cell_x(col), "y": cell_y(row)}
def block_at(row, col):
"""The block covering that square, or None if it is empty."""
for block in blocks:
if block["row"] == row:
if block["col"] <= col < block["col"] + block["length"]:
return block
return None
def can_fall(block):
"""Is every square directly under this block empty?"""
below = block["row"] + 1
if below >= ROWS:
return False
for col in range(block["col"], block["col"] + block["length"]):
if block_at(below, col) is not None:
return False
return True
def row_is_full(row):
for col in range(COLS):
if block_at(row, col) is None:
return False
return True
def make_row(row):
made = []
col = 0
while col < COLS:
if chance(GAP_CHANCE):
col = col + 1
else:
length = choose([1, 2, 3, 4])
if col + length > COLS:
length = COLS - col
made.append(new_block(row, col, length))
col = col + length
filled = 0
for block in made:
filled = filled + block["length"]
if filled == COLS:
made.pop()
return made
# ---------------------------------------------- what the game has to remember
#
# These change while you play. A function that CHANGES one has to say
# `global` first, which means "the one up here, not a new one of my own".
score = 0
busy = False # True while a turn is playing out: no dragging then
flashing = [] # rows that are full and about to go
held = None # the block being dragged, or None
grab_dx = 0 # where on the block the finger is, in pixels
left_limit = 0 # how far the held block may go, in pixels
right_limit = 0
def start_turn():
global busy
busy = True
step()
def step():
global blocks, busy, flashing, score
# Falling, one row per step. Going through the rows from the bottom up
# is what makes it ONE row: a block that has just moved down lands in a
# row this loop has already been through, so it is not moved again until
# the next step. Top down, it would be checked again in its new row, and
# again, and drop to the floor in one go -- and a block resting on it
# would be left behind.
moved = False
for row in range(ROWS - 2, -1, -1):
for block in blocks:
if block["row"] == row and can_fall(block):
block["row"] = row + 1
moved = True
if moved:
wait(STEP_TIME, step)
return
# Clearing. Full rows flash first, and go on the next step.
if len(flashing) > 0:
for row in flashing:
keep = []
for block in blocks:
if block["row"] != row:
keep.append(block)
blocks = keep
score = score + POINTS_PER_ROW * len(flashing) * len(flashing)
flashing = []
wait(STEP_TIME, step)
return
for row in range(ROWS):
if row_is_full(row):
flashing.append(row)
if len(flashing) > 0:
wait(CLEAR_TIME, step)
return
busy = False
def new_game():
global blocks, score, busy, flashing, held
score = 0
busy = False
flashing = []
held = None
blocks = []
for row in range(ROWS - START_ROWS, ROWS):
for block in make_row(row):
blocks.append(block)
@onMousePress
def grab():
global held, grab_dx, left_limit, right_limit
if busy:
return
finger = mousePos()
col = int((finger.x - BOARD_X) // CELL)
row = int((finger.y - BOARD_Y) // CELL)
if col < 0 or col >= COLS or row < 0 or row >= ROWS:
return
block = block_at(row, col)
if block is None:
return
held = block
grab_dx = finger.x - block["x"]
# How far it can go: walk left from the block until a square is taken
# or the board ends, then the same to the right.
left = block["col"]
while left > 0 and block_at(row, left - 1) is None:
left = left - 1
right = block["col"]
while (right + block["length"] < COLS
and block_at(row, right + block["length"]) is None):
right = right + 1
left_limit = cell_x(left)
right_limit = cell_x(right)
@onMouseRelease
def let_go():
global held
if held is None:
return
# Snap to the nearest column.
col = round((held["x"] - BOARD_X) / CELL)
moved = col != held["col"]
held["col"] = col
held = None
# A tap that moved nothing is not a move, so no new row for it.
if moved:
start_turn()
@onUpdate
def glide():
if held is not None:
held["x"] = clamp(mousePos().x - grab_dx, left_limit, right_limit)
# Each frame, cover a good part of the distance that is left. Near
# enough is snapped, so it does not creep for ever.
amount = clamp(dt() * 18, 0, 1)
for block in blocks:
if block is not held:
block["x"] = lerp(block["x"], cell_x(block["col"]), amount)
block["y"] = lerp(block["y"], cell_y(block["row"]), amount)
@onDraw
def draw_everything():
# The board, and a faint square for every empty space.
drawRect(pos=vec2(BOARD_X - 6, BOARD_Y - 6), width=COLS * CELL + 12,
height=ROWS * CELL + 12, color=(50, 50, 80), radius=8)
for row in range(ROWS):
for col in range(COLS):
drawRect(pos=vec2(cell_x(col) + 2, cell_y(row) + 2),
width=CELL - 4, height=CELL - 4, color=(40, 40, 64),
radius=6)
for block in blocks:
colour = COLOURS[block["length"]]
if block["row"] in flashing:
colour = (255, 255, 255)
drawRect(pos=vec2(block["x"] + 3, block["y"] + 3),
width=block["length"] * CELL - 6, height=CELL - 6,
color=colour, radius=10)
drawText(text="Score " + str(score), pos=vec2(BOARD_X, 62), size=24)
new_game()
One step at a time. If the whole turn happened at once,
the board would just change, and you couldn't see why. So
step() does one thing, then
wait(STEP_TIME, step) asks for step to run again
a moment later. Each step, it does the first of these that applies:
- If any block can fall, move every block that can fall down one row.
- If there are flashing rows, remove them and add to the score.
- If any row is full, start it flashing.
- Otherwise the turn is over:
busy = False.
While busy is True, grab refuses to
pick anything up, so you can't drag a block in the middle of a turn.
From the bottom up. The falling loop goes through the rows from the bottom to the top, and that is what makes it one row per step. A block that has just moved down lands in a row the loop has already done, so it isn't moved again until the next step. Go from the top down instead and a falling block is checked again in its new row, and again, and drops to the floor in one go, while a block resting on it is left behind.
The score. One row is worth 10. Two at once is
10 × 2 × 2, which is 40: four times as much for clearing two
together.
A tap is not a move. let_go only starts a
turn if the block ended up in a different column, so tapping a block does
nothing.
Clear every block and there's nothing left to do. On a lucky board that can take only a couple of moves. Step 5 fixes that.
Don't forget global blocks.
Clearing a row builds a new list, keep, and then
blocks = keep. Take blocks out of the
global line and the first move crashes with
UnboundLocalError: cannot access local variable 'blocks' where it is
not associated with a value. The error points at the falling loop near
the top of step(), not at blocks = keep further
down, because one assignment anywhere in a function makes Python treat the
name as the function's own all the way through.
Step 5 — a new row every move
Now the board fights back. After every move, once everything has settled, a new row pushes up from the bottom. Push a block off the top and the game is over.
from kaypy import *
COLS = 8 # squares across
ROWS = 10 # squares down
CELL = 50 # one square is 50 pixels
BOARD_X = 20 # where the board's top-left corner is in the window
BOARD_Y = 110
# Tall rather than wide, to fit a phone held upright.
kaypy(width=COLS * CELL + 40, height=BOARD_Y + ROWS * CELL + 30,
background=[30, 30, 50])
START_ROWS = 4 # rows of blocks on the board when a game starts
GAP_CHANCE = 0.3 # how often a new row has a gap where a block could be
STEP_TIME = 0.08 # seconds between one step of falling and the next
CLEAR_TIME = 0.25 # how long a full row flashes before it goes
POINTS_PER_ROW = 10
# A block's colour depends on how long it is.
COLOURS = {
1: (240, 90, 90),
2: (250, 180, 60),
3: (90, 200, 120),
4: (80, 150, 240),
}
# The name the best score is saved under. Every game on a website shares
# the same storage, so the game's own name goes in it.
BEST_KEY = "block_slide_best"
blocks = []
def cell_x(col):
return BOARD_X + col * CELL
def cell_y(row):
return BOARD_Y + row * CELL
def new_block(row, col, length):
return {"row": row, "col": col, "length": length,
"x": cell_x(col), "y": cell_y(row)}
def block_at(row, col):
"""The block covering that square, or None if it is empty."""
for block in blocks:
if block["row"] == row:
if block["col"] <= col < block["col"] + block["length"]:
return block
return None
def can_fall(block):
"""Is every square directly under this block empty?"""
below = block["row"] + 1
if below >= ROWS:
return False
for col in range(block["col"], block["col"] + block["length"]):
if block_at(below, col) is not None:
return False
return True
def row_is_full(row):
for col in range(COLS):
if block_at(row, col) is None:
return False
return True
def make_row(row):
made = []
col = 0
while col < COLS:
if chance(GAP_CHANCE):
col = col + 1
else:
length = choose([1, 2, 3, 4])
if col + length > COLS:
length = COLS - col
made.append(new_block(row, col, length))
col = col + length
filled = 0
for block in made:
filled = filled + block["length"]
if filled == COLS:
made.pop()
return made
# ---------------------------------------------- what the game has to remember
#
# These change while you play. A function that CHANGES one has to say
# `global` first, which means "the one up here, not a new one of my own".
score = 0
best = getData(BEST_KEY, 0)
busy = False # True while a turn is playing out: no dragging then
flashing = [] # rows that are full and about to go
held = None # the block being dragged, or None
grab_dx = 0 # where on the block the finger is, in pixels
left_limit = 0 # how far the held block may go, in pixels
right_limit = 0
over = False
risen = False # has this turn's new row come up yet?
def start_turn():
global busy, risen
busy = True
risen = False
step()
def step():
global blocks, busy, risen, flashing, score
# Falling, one row per step. Going through the rows from the bottom up
# is what makes it ONE row: a block that has just moved down lands in a
# row this loop has already been through, so it is not moved again until
# the next step. Top down, it would be checked again in its new row, and
# again, and drop to the floor in one go -- and a block resting on it
# would be left behind.
moved = False
for row in range(ROWS - 2, -1, -1):
for block in blocks:
if block["row"] == row and can_fall(block):
block["row"] = row + 1
moved = True
if moved:
wait(STEP_TIME, step)
return
# Clearing. Full rows flash first, and go on the next step.
if len(flashing) > 0:
for row in flashing:
keep = []
for block in blocks:
if block["row"] != row:
keep.append(block)
blocks = keep
score = score + POINTS_PER_ROW * len(flashing) * len(flashing)
flashing = []
wait(STEP_TIME, step)
return
for row in range(ROWS):
if row_is_full(row):
flashing.append(row)
if len(flashing) > 0:
wait(CLEAR_TIME, step)
return
# Rising. Once a turn, after everything has settled.
if not risen:
risen = True
for block in blocks:
block["row"] = block["row"] - 1
for block in make_row(ROWS - 1):
blocks.append(block)
for block in blocks:
if block["row"] < 0:
game_over()
return
wait(STEP_TIME, step)
return
busy = False
def game_over():
global over, best
over = True
shake(8)
if score > best:
best = score
setData(BEST_KEY, best)
message = "New best: " + str(score) + "!"
else:
message = "Score: " + str(score) + "\nBest: " + str(best)
@say("Game over!\n\n" + message, button="Play again")
def again():
new_game()
def new_game():
global blocks, score, busy, flashing, held, over
score = 0
busy = False
flashing = []
held = None
over = False
blocks = []
for row in range(ROWS - START_ROWS, ROWS):
for block in make_row(row):
blocks.append(block)
@onMousePress
def grab():
global held, grab_dx, left_limit, right_limit
if busy or over:
return
finger = mousePos()
col = int((finger.x - BOARD_X) // CELL)
row = int((finger.y - BOARD_Y) // CELL)
if col < 0 or col >= COLS or row < 0 or row >= ROWS:
return
block = block_at(row, col)
if block is None:
return
held = block
grab_dx = finger.x - block["x"]
# How far it can go: walk left from the block until a square is taken
# or the board ends, then the same to the right.
left = block["col"]
while left > 0 and block_at(row, left - 1) is None:
left = left - 1
right = block["col"]
while (right + block["length"] < COLS
and block_at(row, right + block["length"]) is None):
right = right + 1
left_limit = cell_x(left)
right_limit = cell_x(right)
@onMouseRelease
def let_go():
global held
if held is None:
return
# Snap to the nearest column.
col = round((held["x"] - BOARD_X) / CELL)
moved = col != held["col"]
held["col"] = col
held = None
# A tap that moved nothing is not a move, so no new row for it.
if moved:
start_turn()
@onUpdate
def glide():
if held is not None:
held["x"] = clamp(mousePos().x - grab_dx, left_limit, right_limit)
# Each frame, cover a good part of the distance that is left. Near
# enough is snapped, so it does not creep for ever.
amount = clamp(dt() * 18, 0, 1)
for block in blocks:
if block is not held:
block["x"] = lerp(block["x"], cell_x(block["col"]), amount)
block["y"] = lerp(block["y"], cell_y(block["row"]), amount)
@onDraw
def draw_everything():
# The board, and a faint square for every empty space.
drawRect(pos=vec2(BOARD_X - 6, BOARD_Y - 6), width=COLS * CELL + 12,
height=ROWS * CELL + 12, color=(50, 50, 80), radius=8)
for row in range(ROWS):
for col in range(COLS):
drawRect(pos=vec2(cell_x(col) + 2, cell_y(row) + 2),
width=CELL - 4, height=CELL - 4, color=(40, 40, 64),
radius=6)
for block in blocks:
colour = COLOURS[block["length"]]
if block["row"] in flashing:
colour = (255, 255, 255)
drawRect(pos=vec2(block["x"] + 3, block["y"] + 3),
width=block["length"] * CELL - 6, height=CELL - 6,
color=colour, radius=10)
drawText(text="Score " + str(score), pos=vec2(BOARD_X, 62), size=24)
drawText(text="Best " + str(best),
pos=vec2(BOARD_X + 220, 62), size=24)
new_game()
Rising. The new step comes after falling and clearing:
every block's row goes down by one, which moves it up the screen,
and make_row fills the bottom row. risen makes sure
it happens once a turn. Then step carries on as before, because
the new row might leave something able to fall.
Game over. Any block now in row −1 has been pushed off
the top. game_over() saves the score if it's a new best, and
say() shows it with a Play again button.
say() pauses the game while it's up, and over
stops grab picking anything up until the next game starts.
new_game() puts everything back as it
started: the score, the flags, and a fresh board. The same function starts
the very first game and every game after it.
Step 6 — the whole game
This is the game exactly as it is on the games page. It adds:
- Sounds: a swoosh when a block moves, a beep when a row clears, and a boom at game over.
- A message at the start explaining what to do, with a Play button.
- The danger line, a thin red line across the top of the board, so you can see where the edge is.
- A tidy game over. The game-over message pauses the
game, and anything still gliding would freeze halfway, drawn on top of its
neighbour. So
game_overfirst puts every block exactly where it belongs.
It also has every number worth changing in one settings block near the top, and comments that explain why each part is there.
"""Block Slide -- slide the blocks, fill the rows, don't reach the top.
drag a block left or right (a finger on a phone, the mouse elsewhere)
Every block lies in a row. Drag one sideways, as far as the gaps let it go,
and let go. Blocks with nothing under them fall. A row with no gaps left is
cleared, and everything above it falls too. Then a new row of blocks pushes
up from the bottom -- after every single move. If the blocks are pushed off
the top, the game is over. Your best score is remembered.
HOW THIS FILE IS PUT TOGETHER
1. the window
2. the settings
3. the board: a list of blocks, and questions you can ask it
4. making a new row
5. a turn: falling, clearing, rising -- one step at a time
6. dragging
7. drawing
8. start the game
There are no game objects in this game. Every block is a dictionary in a
list, and the board is drawn from that list every frame. Moving a block is
changing a number in its dictionary.
"""
from kaypy import *
# ----------------------------------------------------------------- 1. setup
COLS = 8 # squares across
ROWS = 10 # squares down
CELL = 50 # one square is 50 pixels
BOARD_X = 20 # where the board's top-left corner is in the window
BOARD_Y = 110
# Tall rather than wide, to fit a phone held upright.
kaypy(width=COLS * CELL + 40, height=BOARD_Y + ROWS * CELL + 30,
background=[30, 30, 50])
loadSound("clear", "sounds/rising_beep.wav")
loadSound("slide", "sounds/swoosh.wav")
loadSound("over", "sounds/small_boom.wav")
# -------------------------------------------------------------- 2. settings
#
# Every number worth changing is here.
START_ROWS = 4 # rows of blocks on the board when a game starts
GAP_CHANCE = 0.3 # how often a new row has a gap where a block could be
STEP_TIME = 0.08 # seconds between one step of falling and the next
CLEAR_TIME = 0.25 # how long a full row flashes before it goes
POINTS_PER_ROW = 10
# A block's colour depends on how long it is.
COLOURS = {
1: (240, 90, 90),
2: (250, 180, 60),
3: (90, 200, 120),
4: (80, 150, 240),
}
# The name the best score is saved under. Every game on a website shares
# the same storage, so the game's own name goes in it.
BEST_KEY = "block_slide_best"
# ------------------------------------------------------------- 3. the board
#
# Every block is a dictionary:
#
# {"row": 9, "col": 2, "length": 3, "x": ..., "y": ...}
#
# row 0 is the top and row 9 the bottom; col 0 is the left. A block of
# length 3 at col 2 covers columns 2, 3 and 4. "x" and "y" are where it is
# DRAWN, in pixels -- they glide towards where it really is, so blocks slide
# and fall smoothly instead of jumping.
blocks = []
def cell_x(col):
return BOARD_X + col * CELL
def cell_y(row):
return BOARD_Y + row * CELL
def new_block(row, col, length):
return {"row": row, "col": col, "length": length,
"x": cell_x(col), "y": cell_y(row)}
def block_at(row, col):
"""The block covering that square, or None if it is empty."""
for block in blocks:
if block["row"] == row:
if block["col"] <= col < block["col"] + block["length"]:
return block
return None
def can_fall(block):
"""Is every square directly under this block empty?"""
below = block["row"] + 1
if below >= ROWS:
return False
for col in range(block["col"], block["col"] + block["length"]):
if block_at(below, col) is not None:
return False
return True
def row_is_full(row):
for col in range(COLS):
if block_at(row, col) is None:
return False
return True
# -------------------------------------------------------- 4. a new row
#
# Walk along the row from the left. Each time: either leave a gap, or put
# down a block of random length that still fits. A full row would clear
# the moment it arrived, so if the row happens to come out full, the last
# block is taken away again.
def make_row(row):
made = []
col = 0
while col < COLS:
if chance(GAP_CHANCE):
col = col + 1
else:
length = choose([1, 2, 3, 4])
if col + length > COLS:
length = COLS - col
made.append(new_block(row, col, length))
col = col + length
filled = 0
for block in made:
filled = filled + block["length"]
if filled == COLS:
made.pop()
return made
# ---------------------------------------------- what the game has to remember
#
# These change while you play. A function that CHANGES one has to say
# `global` first, which means "the one up here, not a new one of my own".
score = 0
best = getData(BEST_KEY, 0)
busy = False # True while a turn is playing out: no dragging then
flashing = [] # rows that are full and about to go
held = None # the block being dragged, or None
grab_dx = 0 # where on the block the finger is, in pixels
left_limit = 0 # how far the held block may go, in pixels
right_limit = 0
over = False
# ---------------------------------------------------------- 5. a turn
#
# After a move, the turn plays out one step at a time, with a short wait
# between steps so you can see what happens:
#
# anything that can fall, falls one row (again, until nothing can)
# full rows flash, then go (then back to falling)
# a new row pushes up from the bottom (then back to falling)
#
# step() does ONE of those and then waits and calls itself again, until
# there is nothing left to do.
risen = False # has this turn's new row come up yet?
def start_turn():
global busy, risen
busy = True
risen = False
step()
def step():
global blocks, busy, risen, flashing, score
# Falling, one row per step. Going through the rows from the bottom up
# is what makes it ONE row: a block that has just moved down lands in a
# row this loop has already been through, so it is not moved again until
# the next step. Top down, it would be checked again in its new row, and
# again, and drop to the floor in one go -- and a block resting on it
# would be left behind.
moved = False
for row in range(ROWS - 2, -1, -1):
for block in blocks:
if block["row"] == row and can_fall(block):
block["row"] = row + 1
moved = True
if moved:
wait(STEP_TIME, step)
return
# Clearing. Full rows flash first, and go on the next step.
if len(flashing) > 0:
for row in flashing:
keep = []
for block in blocks:
if block["row"] != row:
keep.append(block)
blocks = keep
score = score + POINTS_PER_ROW * len(flashing) * len(flashing)
flashing = []
wait(STEP_TIME, step)
return
for row in range(ROWS):
if row_is_full(row):
flashing.append(row)
if len(flashing) > 0:
play("clear")
wait(CLEAR_TIME, step)
return
# Rising. Once a turn, after everything has settled.
if not risen:
risen = True
for block in blocks:
block["row"] = block["row"] - 1
for block in make_row(ROWS - 1):
blocks.append(block)
for block in blocks:
if block["row"] < 0:
game_over()
return
wait(STEP_TIME, step)
return
busy = False
def game_over():
global over, best
over = True
play("over")
shake(8)
# The message pauses the game, which would freeze any block halfway
# through gliding to its square. Put every block where it really is.
for block in blocks:
block["x"] = cell_x(block["col"])
block["y"] = cell_y(block["row"])
if score > best:
best = score
setData(BEST_KEY, best)
message = "New best: " + str(score) + "!"
else:
message = "Score: " + str(score) + "\nBest: " + str(best)
@say("Game over!\n\n" + message, button="Play again")
def again():
new_game()
def new_game():
global blocks, score, busy, flashing, held, over
score = 0
busy = False
flashing = []
held = None
over = False
blocks = []
for row in range(ROWS - START_ROWS, ROWS):
for block in make_row(row):
blocks.append(block)
# ---------------------------------------------------------- 6. dragging
#
# A finger on a phone and the mouse on a computer are the same thing to
# kaypy: onMousePress, mousePos() and onMouseRelease answer to both.
@onMousePress
def grab():
global held, grab_dx, left_limit, right_limit
if busy or over:
return
finger = mousePos()
col = int((finger.x - BOARD_X) // CELL)
row = int((finger.y - BOARD_Y) // CELL)
if col < 0 or col >= COLS or row < 0 or row >= ROWS:
return
block = block_at(row, col)
if block is None:
return
held = block
grab_dx = finger.x - block["x"]
# How far it can go: walk left from the block until a square is taken
# or the board ends, then the same to the right.
left = block["col"]
while left > 0 and block_at(row, left - 1) is None:
left = left - 1
right = block["col"]
while (right + block["length"] < COLS
and block_at(row, right + block["length"]) is None):
right = right + 1
left_limit = cell_x(left)
right_limit = cell_x(right)
@onMouseRelease
def let_go():
global held
if held is None:
return
# Snap to the nearest column.
col = round((held["x"] - BOARD_X) / CELL)
moved = col != held["col"]
held["col"] = col
held = None
# A tap that moved nothing is not a move, so no new row for it.
if moved:
play("slide")
start_turn()
# ------------------------------------------------------------ 7. drawing
#
# Every frame: the dragged block follows the finger, every other block
# glides towards where it really is, and then everything is drawn.
@onUpdate
def glide():
if held is not None:
held["x"] = clamp(mousePos().x - grab_dx, left_limit, right_limit)
# Each frame, cover a good part of the distance that is left. Near
# enough is snapped, so it does not creep for ever.
amount = clamp(dt() * 18, 0, 1)
for block in blocks:
if block is not held:
block["x"] = lerp(block["x"], cell_x(block["col"]), amount)
block["y"] = lerp(block["y"], cell_y(block["row"]), amount)
@onDraw
def draw_everything():
# The board, and a faint square for every empty space.
drawRect(pos=vec2(BOARD_X - 6, BOARD_Y - 6), width=COLS * CELL + 12,
height=ROWS * CELL + 12, color=(50, 50, 80), radius=8)
for row in range(ROWS):
for col in range(COLS):
drawRect(pos=vec2(cell_x(col) + 2, cell_y(row) + 2),
width=CELL - 4, height=CELL - 4, color=(40, 40, 64),
radius=6)
# The top row is the danger line: anything pushed above it ends the game.
drawRect(pos=vec2(BOARD_X, BOARD_Y - 4), width=COLS * CELL, height=3,
color=(240, 90, 90))
for block in blocks:
colour = COLOURS[block["length"]]
if block["row"] in flashing:
colour = (255, 255, 255)
drawRect(pos=vec2(block["x"] + 3, block["y"] + 3),
width=block["length"] * CELL - 6, height=CELL - 6,
color=colour, radius=10)
drawText(text="Block Slide", pos=vec2(BOARD_X, 18), size=32)
drawText(text="Score " + str(score), pos=vec2(BOARD_X, 62), size=24)
drawText(text="Best " + str(best),
pos=vec2(BOARD_X + 220, 62), size=24)
# ------------------------------------------------------- 8. start the game
new_game()
say("Drag a block left or right to fill a row.\n\n"
"Every move, a new row comes up from the bottom. "
"Don't let the blocks reach the top!", button="Play")
Make it yours
Open the whole game in the playground → It goes straight into the editor, ready to change and run.
- Change the board.
COLSandROWSchange its size; the window grows to fit. A wider board is harder, because rows take more filling. - Change the rows. A bigger
GAP_CHANCEmakes emptier rows, which are easier.START_ROWSis how full the board is when a game begins. - Longer blocks. Add
5to the list inmake_row, and giveCOLOURSa colour for5. Forget the colour and the first block of length 5 stops the game withKeyError: 5, because there's nothing inCOLOURSto look up. - A slower turn. Make
STEP_TIMEbigger and watch the blocks fall one row at a time.