Lesson 06: Spatial Vectors & 2D Grids
More than 40% of Advent of Code puzzles involve 2D maps, mazes, directional walks, or 3D/4D coordinate lattices. In this lesson, you will master Tinsel's native spatial types (vec2 and vec3), the <-> Manhattan metric operator, N-dimensional vectors (std/vec), and the contiguous 2D grid engine (std/grid).
1. Learning Objectives
By the end of this lesson, you will be able to:
- Perform 2D and 3D coordinate arithmetic with
vec2(x, y)andvec3(x, y, z). - Compute taxicab distances with the infix
<->operator and generate neighbors with.adj4(),.adj8(),.adj6(), and.adj26(). - Parse, index (
g[pos]), bounds-check (pos in g), and mutate 2D grids (std/grid). - Run flood fills, rotate/flip/transpose grids, and step along directional headings.
2. 2D & 3D Coordinate Vectors (vec2 & vec3)
vec2(x, y) and vec3(x, y, z) are immutable integer coordinate vectors. When a vec2's coordinates fit in signed 32-bit integers, the VM packs both coordinates directly into the 64-bit Value.Num slot with zero heap allocation!
let p1 = vec2(4, -3)
let p2 = vec2(2, 5)
// Component-wise addition, subtraction, negation, and scalar multiplication:
assert(p1 + p2 == vec2(6, 2))
assert(p1 - p2 == vec2(2, -8))
assert(-p1 == vec2(-4, 3))
assert(p2 * 3 == vec2(6, 15))
assert(p1.x == 4 && p1.y == -3)
The Manhattan Distance Operator (<->)
The infix <-> operator computes Manhattan (L_1) distance in 1D (int/float), 2D (vec2), or 3D (vec3):
p_1 \leftrightarrow p_2 = |x_1 - x_2| + |y_1 - y_2| (+ |z_1 - z_2|)
assert((vec2(1, 2) <-> vec2(4, 6)) == 7)
assert((vec3(1, 2, 3) <-> vec3(4, 6, 8)) == 12)
assert((10 <-> 25) == 15)
Adjacency Generators
Vectors generate their neighboring coordinates in a single method call:
v2.adj4(): 4 cardinal neighbors[Up, Right, Down, Left].v2.adj8(): 8 surrounding neighbors (cardinals + diagonals).v3.adj6(): 6 orthogonal 3D face neighbors.v3.adj26(): All 26 surrounding 3D neighbors.
assert(vec2(5, 5).adj4().len() == 4)
assert(vec2(5, 5).adj8().len() == 8)
assert(vec3(0, 0, 0).adj6().len() == 6)
assert(vec3(0, 0, 0).adj26().len() == 26)
N-Dimensional Coordinates (std/vec)
For 4D or higher-dimensional puzzles (such as 4D Conway Hypercubes in AoC 2020 Day 17), import std/vec to work with [int] coordinate arrays of any dimension N:
import "std/vec" as vec
let origin_4d = vec.zero(4) // [0, 0, 0, 0]
assert(vec.adj_ortho(origin_4d).len() == 8) // 2 * 4 = 8
assert(vec.adj_all(origin_4d).len() == 80) // 3^4 - 1 = 80
3. The 2D Character Grid Engine (std/grid)
Tinsel's grid type stores a 2D character board in a flat row-major array (y * width + x) with prototype hooks for indexing and membership.
Creating & Querying a Grid
Parse any multiline string with s.grid() or parse_grid(s) (from std/grid), or create a blank grid of dimensions W \times H with grid.new(w, h, fill_char):
from "std/grid" import parse_grid
let maze = "#####\n#S..#\n#.###\n#..E#\n#####".grid()
// Dimensions are stored as integer properties (.width / .w and .height / .h):
assert(maze.width == 5 && maze.height == 5)
// Locate characters with .find(ch) and .find_all(ch):
let start = maze.find('S')
let goal = maze.find('E')
let walls = maze.find_all('#')
assert(start == vec2(1, 1) && goal == vec2(3, 3))
assert(walls.len() == 18 && maze.count('.') == 5)
Indexing (g[pos]), Safe .get(pos) & Bounds Membership (pos in g)
pos in g(org.in_bounds(pos)): Returnstrueif0 <= pos.x < g.widthand0 <= pos.y < g.height.g[pos]andg[pos] = ch: Directvec2bracket indexing and in-place mutation (panics ifposis out of bounds).g.get(pos): Safe lookup that returnsnilwhenposis out of bounds.
let g = "..\n.#".grid()
let p = vec2(0, 0)
assert(p in g && vec2(5, 5) !in g)
assert(g[p] == '.' && g.get(vec2(9, 9)) == nil)
g[p] = '@'
assert(g[p] == '@')
Boundary-Aware Grid Neighbors (g.adj4(pos) & g.adj8(pos))
Unlike pos.adj4(), calling g.adj4(pos) or g.adj8(pos) on a grid automatically filters out any coordinates that fall outside the grid's boundaries:
let g = "...\n...\n...".grid()
// Top-left corner (0, 0) only has 2 in-bounds cardinal neighbors and 3 in-bounds 8-way neighbors:
assert(g.adj4(vec2(0, 0)) == [vec2(1, 0), vec2(0, 1)])
assert(g.adj8(vec2(0, 0)).len() == 3)
4. Headings, Flood Fill & Grid Transformations
Directional Headings (std/grid)
std/grid exports cardinal_dirs ([East, South, West, North] = [vec2(1, 0), vec2(0, 1), vec2(-1, 0), vec2(0, -1)]) and rotation helpers turn_right(dir), turn_left(dir), opposite(dir), and forward(pos, dir):
import "std/grid" as grid
let mut pos = vec2(0, 0)
let mut dir = 0 // 0 = East
pos = grid.forward(pos, dir) // Vec2(1, 0)
dir = grid.turn_right(dir) // 1 = South
pos = grid.forward(pos, dir) // Vec2(1, 1)
assert(pos == vec2(1, 1))
Connected-Component Flood Fill (.flood_fill)
g.flood_fill(start, [can_visit]) returns all reachable vec2 coordinates in the connected component starting at start:
let map_g = "###..\n#....\n###..".grid()
// Pass a target char or a (curr_pos, next_pos) -> bool closure:
let region = map_g.flood_fill(vec2(1, 1), '.')
assert(region.len() == 8)
Grid Rotations, Reflections & Transposition
Transform an entire grid in one call with .rotate_cw(), .rotate_ccw(), .flip_h(), .flip_v(), .transpose(), .rows(), .cols(), and .clone():
let g = "AB\nCD".grid()
assert(g.rotate_cw().rows() == ["CA", "DB"])
assert(g.flip_h().rows() == ["BA", "DC"])
assert(g.transpose().rows() == ["AC", "BD"])
5. Common Pitfalls & Gotchas
Gotcha 1: g.width and g.height are fields, not methods
Access grid dimensions via g.width (or g.w) and g.height (or g.h). Do not write g.width().
Gotcha 2: (x, y) vs. (row, col) Coordinate Order
In vec2(x, y), x is the horizontal column (0 ..< g.width) and y is the vertical row (0 ..< g.height), with vec2(0, 0) at the top-left corner and +y pointing South (downward).
6. Hands-On Exercises
Exercise 6.1: Guard Patrol Walk (Warmup)
Start at 'S' facing North (dir = 3) in a 5 \times 5 room. Walk forward until hitting '#', turn right (grid.turn_right(dir)), and take 2 more steps. Verify the final coordinate.
Solution & Explanation
import "std/grid" as grid
let room = "#####\n#...#\n#...#\n#.S.#\n#####".grid()
let mut pos = room.find('S')
let mut dir = 3 // North
while room[grid.forward(pos, dir)] != '#' {
pos = grid.forward(pos, dir)
}
dir = grid.turn_right(dir) // East
pos = grid.forward(pos, dir)
assert(pos == vec2(3, 1))
println("Final guard pos:", pos)
Exercise 6.2: Accessible Rolls Counter (Practice)
In a warehouse grid of @ paper rolls and . empty floor, a roll @ is accessible if fewer than 4 of its 8 surrounding in-bounds neighbors (g.adj8(pos)) are also @. Count how many @ rolls are accessible.
Solution & Explanation
let g = "..@@.@@@@.\n@@@.@.@.@@\n@@@@@.@.@@".grid()
let accessible = g.find_all('@').filter(pos -> {
g.adj8(pos).filter(nb -> g[nb] == '@').len() < 4
})
assert(accessible.len() == 11)
println("Accessible rolls:", accessible.len())
Exercise 6.3: Conway's Game of Life Oscillator (Challenge)
Given the 3 \times 3 vertical blinker ".#.\n.#.\n.#.", write a step_life(g) function that computes the next generation on a .clone() grid and verify that 1 step produces a horizontal blinker "...\n###\n...".
Solution & Explanation
fn step_life(g) {
let next = g.clone()
for [pos, ch] in g.cells() {
let live_nb = g.adj8(pos).filter(n -> g[n] == '#').len()
next[pos] = if ch == '#' {
if live_nb == 2 || live_nb == 3 { '#' } else { '.' }
} else {
if live_nb == 3 { '#' } else { '.' }
}
}
next
}
let g0 = ".#.\n.#.\n.#.".grid()
let g1 = step_life(g0)
assert(g1.to_string() == "...\n###\n...")
println("Next generation:\n" + g1.to_string())
Next Steps
In Lesson 07: Interval Algebra & Orbit Cycle Detection, you will learn how to conquer Part 2 scale explosions (10^{10} coordinates and 10^{12} simulation steps) in microseconds.