❄️ Tinsel AoC DSL

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) and vec3(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!

tinsel
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|)

tinsel
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.
tinsel
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:

tinsel
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):

tinsel
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 (or g.in_bounds(pos)): Returns true if 0 <= pos.x < g.width and 0 <= pos.y < g.height.
  • g[pos] and g[pos] = ch: Direct vec2 bracket indexing and in-place mutation (panics if pos is out of bounds).
  • g.get(pos): Safe lookup that returns nil when pos is out of bounds.
tinsel
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:

tinsel
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):

tinsel
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:

tinsel
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():

tinsel
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

Warning

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().

Warning

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