Lesson 06

Gravity: making pieces fall on a timer

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.

▸ By the end of this lesson: A piece that falls one row per tick and stops when it can't move down

6.1Give Main a real board and a fall timer

Update main.gd's top section so Main owns a Board instance and tracks fall timing:

main.gd (top)gdscript
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.0

Initialize the board in _ready():

main.gd (_ready)gdscript
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.

6.2Move the piece down each tick

Add the fall logic. In _process, accumulate time; when it exceeds the interval, try to step the piece down one row.

main.gdgdscript
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 false

6.3Collision, but against data — not sprites

The whole game hinges on this: before moving, ask the board whether every target cell is free. Add the collision test to main.gd:

main.gdgdscript
# 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 true

One 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.

6.4Give Piece a move method

Add this to piece.gd so the piece can shift and redraw:

piece.gd (append)gdscript
# Shift the piece by a grid delta and update visuals.
func move_by(delta_grid: Vector2i) -> void:
	origin += delta_grid
	_redraw()

6.5Locking: stamp the piece into the board

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:

main.gdgdscript
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.

6.6Render the locked board

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.

main.gdgdscript
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])
  1. Run with F5. A piece spawns and falls one row every 0.6s.
  2. When it hits the floor it locks in place (its squares stay), and a new piece spawns.
  3. Let several pieces stack up at the bottom.

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