Lesson 04

The board as data: separating logic from visuals

The single most important idea in GridFall: the board is a 2D array of numbers, and the colored squares are just a picture of that array. Get this separation right and clearing, collision, and game-over all become easy.

▸ By the end of this lesson: A Board data structure with helpers to read, write, and query any cell safely

4.1Why a data grid at all

Beginners try to ask the sprites where they are. That gets messy fast. Instead we keep a plain 2D array — the logical board — as the truth, and redraw squares to match it. Collision becomes "is that array slot filled?" Clearing becomes "is this row all non-empty?"

text
# Conceptually, board is ROWS lists of COLS ints.
# 0 = empty. Any other number = a filled cell (we'll store a color id).
#
#   board[row][col]
#
#   row 0  [0,0,0,0,0,0,0,0,0,0]   <- top
#   ...
#   row 17 [0,0,3,3,0,0,0,0,0,0]   <- bottom, two filled cells
#                ^ col 2, col 3

4.2Create the Board script

We'll put board logic in its own script so it stays testable and separate from drawing. Create a new script file (in the FileSystem dock: right-click → New Script) named board.gd:

board.gdgdscript
extends RefCounted
class_name Board

# A pure-data model of the play field. No nodes, no drawing.
# RefCounted = a lightweight object that frees itself automatically.

var cols: int
var rows: int
var grid: Array = []   # 2D array of ints; 0 means empty

func _init(p_cols: int, p_rows: int) -> void:
	cols = p_cols
	rows = p_rows
	clear()

# Reset every slot to empty.
func clear() -> void:
	grid = []
	for r in range(rows):
		var row_data: Array = []
		for c in range(cols):
			row_data.append(0)
		grid.append(row_data)

# Is (col, row) inside the well at all?
func in_bounds(col: int, row: int) -> bool:
	return col >= 0 and col < cols and row >= 0 and row < rows

# Is this slot empty AND on the board? Safe to call with any numbers.
func is_free(col: int, row: int) -> bool:
	if not in_bounds(col, row):
		return false
	return grid[row][col] == 0

# Write a value (0 = clear, >0 = a color id) into a slot.
func set_cell(col: int, row: int, value: int) -> void:
	if in_bounds(col, row):
		grid[row][col] = value

# Read a slot (returns 0 if out of bounds).
func get_cell(col: int, row: int) -> int:
	if in_bounds(col, row):
		return grid[row][col]
	return 0

RefCounted, not Node

Board isn't in the scene tree — it's just data. Extending RefCounted means it's cleaned up automatically when nothing references it. Keeping pure logic out of nodes makes it easy to reason about and, later, to test.

4.3The row-clear query — the heart of scoring

Add these methods to board.gd. They find and remove full rows and report how many were cleared.

board.gd (append)gdscript
# True if every column in this row is filled.
func is_row_full(row: int) -> bool:
	for c in range(cols):
		if grid[row][c] == 0:
			return false
	return true

# Remove all full rows, drop everything above down, return count cleared.
func clear_full_rows() -> int:
	var cleared: int = 0
	# Walk from the bottom up so shifting doesn't skip rows.
	var r: int = rows - 1
	while r >= 0:
		if is_row_full(r):
			_collapse_into(r)
			cleared += 1
			# Do NOT decrement r: the collapsed contents now occupy
			# this row and must be checked again.
		else:
			r -= 1
	return cleared

# Shift every row above target_row down by one, empty the top row.
func _collapse_into(target_row: int) -> void:
	for r in range(target_row, 0, -1):
		grid[r] = grid[r - 1].duplicate()
	# New empty top row.
	var top: Array = []
	for c in range(cols):
		top.append(0)
	grid[0] = top

The classic clear bug

When you delete a full row and shift everything down, the row you just filled might also be full. That's why we don't decrement r after a clear — we re-test the same index. Skip this and stacked line-clears silently miss rows.

duplicate() matters

grid[r] = grid[r-1] would make both rows point at the same array. .duplicate() copies the values. Forgetting this is a subtle bug where editing one row changes another.

4.4Quick self-test in _ready

You can verify the logic without any graphics. Temporarily, in main.gd _ready():

main.gd (temporary)gdscript
	# --- temporary board test ---
	var b := Board.new(COLS, ROWS)
	# Fill the bottom row completely.
	for c in range(COLS):
		b.set_cell(c, ROWS - 1, 1)
	print("Bottom row full? ", b.is_row_full(ROWS - 1))   # true
	print("Cleared: ", b.clear_full_rows())               # 1
	print("Bottom row full now? ", b.is_row_full(ROWS - 1)) # false
  1. Run with F5 and read the Output panel: true, then 1, then false.
  2. If you see that, your board logic is correct. Remove the temporary test block.

Checkpoint — expected state