Lesson 06
Pieces should drop one row at a time on a steady beat. We'll use a Timer node, move the piece down each tick, and — critically — check the data board so pieces stop instead of falling through the floor.
Update main.gd's top section so Main owns a Board instance and tracks fall timing:
extends Node2D
const COLS: int = 10
const ROWS: int = 18
const CELL_SIZE: int = 32
const WELL_WIDTH: int = COLS * CELL_SIZE
const WELL_HEIGHT: int = ROWS * CELL_SIZE
const PieceScene: PackedScene = preload("res://piece.tscn")
const SPAWN_ORIGIN: Vector2i = Vector2i(4, 1)
var board: Board
var active_piece: Piece = null
# Fall speed: seconds between automatic drops.
var fall_interval: float = 0.6
var _fall_accum: float = 0.0Initialize the board in _ready():
func _ready() -> void:
board = Board.new(COLS, ROWS)
_draw_border()
spawn_piece()Timer node vs delta accumulator
Two ways to get a beat: a Timer node, or accumulate delta in _process. We use the accumulator because it makes speeding up the game later trivial — just shrink fall_interval. No node wiring needed.
Add the fall logic. In _process, accumulate time; when it exceeds the interval, try to step the piece down one row.
func _process(delta: float) -> void:
if active_piece == null:
return
_fall_accum += delta
if _fall_accum >= fall_interval:
_fall_accum = 0.0
_step_down()
# Try to move the active piece down one row.
# Returns true if it moved, false if it was blocked.
func _step_down() -> bool:
if _can_move(active_piece, Vector2i(0, 1)):
active_piece.move_by(Vector2i(0, 1))
return true
else:
_lock_piece()
return falseThe whole game hinges on this: before moving, ask the board whether every target cell is free. Add the collision test to main.gd:
# Would the piece fit if we nudged it by "delta_grid"?
func _can_move(piece: Piece, delta_grid: Vector2i) -> bool:
for cell in piece.get_cells():
var target: Vector2i = cell + delta_grid
if not board.is_free(target.x, target.y):
return false
return trueOne test, every direction
_can_move works for falling, left, right, and even rotation — anything that produces a new set of cells. We reuse it in the next two lessons. Write it once, correctly, here.
Add this to piece.gd so the piece can shift and redraw:
# Shift the piece by a grid delta and update visuals.
func move_by(delta_grid: Vector2i) -> void:
origin += delta_grid
_redraw()When a piece can't fall, its cells become permanent. We write them into the board data, free the piece node, and spawn the next one. Add to main.gd:
func _lock_piece() -> void:
# Stamp each occupied cell into the board with this piece's color id.
for cell in active_piece.get_cells():
board.set_cell(cell.x, cell.y, active_piece.color_id)
# We still need to SEE the locked cells. Leave the piece's visual
# cells on screen by reparenting them, or simpler: draw the board.
# For now, keep it simple — free the piece and redraw the whole board.
active_piece.queue_free()
active_piece = null
_render_board()
spawn_piece()queue_free, not free
queue_free() deletes a node safely at the end of the frame. Calling free() mid-logic can crash if something still references the node this frame. Always queue_free() for scene nodes.
We need a way to draw the settled cells. Add a board renderer to main.gd that rebuilds the locked squares from data. We keep them under a dedicated child node so they're easy to clear and redraw.
const CellScene: PackedScene = preload("res://cell.tscn")
var _board_layer: Node2D
func _ensure_board_layer() -> void:
if _board_layer == null:
_board_layer = Node2D.new()
add_child(_board_layer)
# Redraw every filled board cell from scratch. Simple and correct;
# we optimize only if we ever need to.
func _render_board() -> void:
_ensure_board_layer()
for child in _board_layer.get_children():
child.queue_free()
for r in range(ROWS):
for c in range(COLS):
var id: int = board.get_cell(c, r)
if id != 0:
var cell: Cell = CellScene.instantiate()
_board_layer.add_child(cell)
cell.set_grid_position(Vector2i(c, r))
cell.set_color(Shapes.COLORS[id])Clear the accumulator on lock
Because we reset _fall_accum only when it fires, the freshly spawned piece falls on a clean beat. If you ever see the first drop happen instantly, reset _fall_accum = 0.0 inside spawn_piece() too.
Checkpoint — expected state
fall_interval using a delta accumulator._can_move tests the data board — the reusable collision check.