Odin and Me ยท Chapter 5

Chapter 5: ASCII figures: nested loops as a drawing tool

This chapter:

  • Draw a right-angled triangle with two nested loops.
  • Work out a centring formula for a row, instead of memorising one.
  • Turn "draw one padded row" into a small, reusable procedure.
  • Build a diamond and a number pyramid on top of that one procedure.
  • Call your shape procedures with a few different sizes and characters.

Where we left off: Chapter 4 was about handling text you're given. This chapter is about producing text: specifically, shapes made of rows and columns of characters, which is where nested loops start to earn their keep.


5.1 Rows and columns

Every ASCII figure in this chapter comes from the same two-loop shape: an outer loop for the row, an inner loop for what goes in that row. Start with the simplest version, a right-angled triangle:

height := 5
for row in 1 ..= height {
	for col in 1 ..= row {
		fmt.print("*")
	}
	fmt.println()
}
*
**
***
****
*****

Read it out loud: "for each row from 1 to height, print row asterisks, then start a new line." The outer loop picks which row you're on; the inner loop decides how much that row prints. Once that sentence is obvious, every other shape in this chapter is a variation on it.

Odin note: 1 ..= height, not 0 ..< height. Chapter 2 taught you to reach for ..< when counting up to a length. Here you want row number 1 through 5, not an index 0 through 4; using ..= (inclusive) keeps row matching what you'd actually call each line if you were counting them by hand.

5.2 Centring

A diamond needs each row centred, not left-aligned, which means every row needs some leading spaces before its asterisks. Work the formula out rather than guessing at it: a row row in a triangle of height rows prints 2*row - 1 asterisks (1, 3, 5, 7, 9, always odd), and the padding needed to centre that inside the triangle's full width is height - row spaces.

height := 5
for row in 1 ..= height {
	fill := 2*row - 1
	pad := height - row
	for _ in 0 ..< pad {
		fmt.print(" ")
	}
	for _ in 0 ..< fill {
		fmt.print("*")
	}
	fmt.println()
}
    *
   ***
  *****
 *******
*********

Why derive the formula instead of just copying it? Because the moment you change the shape (five rows becomes eight, a triangle becomes a diamond, * becomes a digit) a memorised formula stops working and a memorised habit doesn't tell you why. fill = 2*row - 1 and pad = height - row both fall straight out of "row 1 is the narrowest, row height is the widest, and every row is centred inside the same overall width": reason from that sentence and you can rebuild the formula for any shape, not just this one.

5.3 A reusable row-drawing procedure

Both loop bodies above, minus the maths, do the same job: print some spaces, then some of a character, then a newline. Factor that out once and reuse it for every shape in this chapter, including a diamond in a moment:

draw_row :: proc(fill: int, ch: rune, pad := 0) {
	b: strings.Builder
	strings.builder_init(&b)
	defer strings.builder_destroy(&b)

	for _ in 0 ..< pad {
		strings.write_byte(&b, ' ')
	}
	for _ in 0 ..< fill {
		strings.write_rune(&b, ch)
	}
	fmt.println(strings.to_string(b))
}

draw_row takes how many characters to print (fill), which character (ch), and how much padding to put in front (pad, defaulting to 0, Chapter 2's default-argument syntax, so a left-aligned row like ยง5.1's triangle can call draw_row(row, ch) and skip the padding argument entirely). It builds the row with ยง4.4's strings.Builder rather than several separate fmt.print calls, and prints the whole row in one go.

With draw_row in hand, the right-angled triangle from ยง5.1 collapses to one line per row:

draw_triangle :: proc(height: int, ch: rune) {
	for row in 1 ..= height {
		draw_row(row, ch)
	}
}

5.4 A diamond

An isoceles triangle is ยง5.2's centred loop, expressed with draw_row:

draw_isoceles :: proc(height: int, ch: rune) {
	for row in 1 ..= height {
		fill := 2*row - 1
		draw_row(fill, ch, height - row)
	}
}

A diamond is that triangle, then the same rows again in reverse, skipping the widest row so it doesn't repeat:

draw_diamond :: proc(height: int, ch: rune) {
	draw_isoceles(height, ch)
	for row := height - 1; row >= 1; row -= 1 {
		fill := 2*row - 1
		draw_row(fill, ch, height - row)
	}
}
    *
   ***
  *****
 *******
*********
 *******
  *****
   ***
    *

Notice what didn't have to change: draw_row doesn't know or care that it's being used to build a diamond instead of a triangle. That's the payoff of factoring "draw one padded row" out as its own procedure back in ยง5.3: every new shape from here on is just a different pattern of calls to it.

5.5 A pyramid of numbers

The same centred shape, but each row prints ascending-then-mirrored digits instead of a fixed character: row 3 prints 121, row 4 prints 1234321's narrower cousin 12321, and so on. This one needs its own small loop rather than a single call to draw_row, since the characters aren't all the same:

draw_pyramid_of_numbers :: proc(height: int) {
	for row in 1 ..= height {
		b: strings.Builder
		strings.builder_init(&b)
		defer strings.builder_destroy(&b)

		for _ in 0 ..< height - row {
			strings.write_byte(&b, ' ')
		}
		for n in 1 ..= row {
			strings.write_byte(&b, '0' + u8(n % 10))
		}
		for n := row - 1; n >= 1; n -= 1 {
			strings.write_byte(&b, '0' + u8(n % 10))
		}
		fmt.println(strings.to_string(b))
	}
}
    1
   121
  12321
 1234321
123454321

'0' + u8(n % 10) turns a digit's numeric value back into its printable character: '0' is byte 48, and adding n % 10 to it walks forward through '0' .. '9' the same way 'a' + 1 would give you 'b'. The % 10 keeps the pyramid legible past row 9, wrapping 10 back down to the digit 0 rather than printing a two-character 10.

Odin note: '0' is a rune literal. Single-quoted characters in Odin are rune values, not one-character strings; '0' + u8(n % 10) mixes a rune and a u8 deliberately, converting the small offset to match. This is the same explicit-conversion discipline from Chapter 2: Odin won't silently blend the two types for you.

Call everything together with a few sizes and characters, so we can see our code in action:

// shapes.odin
package main

import "core:fmt"
import "core:strings"

draw_row :: proc(fill: int, ch: rune, pad := 0) {
	b: strings.Builder
	strings.builder_init(&b)
	defer strings.builder_destroy(&b)

	for _ in 0 ..< pad {
		strings.write_byte(&b, ' ')
	}
	for _ in 0 ..< fill {
		strings.write_rune(&b, ch)
	}
	fmt.println(strings.to_string(b))
}

draw_triangle :: proc(height: int, ch: rune) {
	for row in 1 ..= height {
		draw_row(row, ch)
	}
}

draw_isoceles :: proc(height: int, ch: rune) {
	for row in 1 ..= height {
		fill := 2*row - 1
		draw_row(fill, ch, height - row)
	}
}

draw_diamond :: proc(height: int, ch: rune) {
	draw_isoceles(height, ch)
	for row := height - 1; row >= 1; row -= 1 {
		fill := 2*row - 1
		draw_row(fill, ch, height - row)
	}
}

draw_pyramid_of_numbers :: proc(height: int) {
	for row in 1 ..= height {
		b: strings.Builder
		strings.builder_init(&b)
		defer strings.builder_destroy(&b)

		for _ in 0 ..< height - row {
			strings.write_byte(&b, ' ')
		}
		for n in 1 ..= row {
			strings.write_byte(&b, '0' + u8(n % 10))
		}
		for n := row - 1; n >= 1; n -= 1 {
			strings.write_byte(&b, '0' + u8(n % 10))
		}
		fmt.println(strings.to_string(b))
	}
}

main :: proc() {
	fmt.println("-- right triangle --")
	draw_triangle(5, '*')

	fmt.println("-- isoceles triangle --")
	draw_isoceles(5, '*')

	fmt.println("-- diamond --")
	draw_diamond(5, '#')

	fmt.println("-- number pyramid --")
	draw_pyramid_of_numbers(5)
}
-- right triangle --
*
**
***
****
*****
-- isoceles triangle --
    *
   ***
  *****
 *******
*********
-- diamond --
    #
   ###
  #####
 #######
#########
 #######
  #####
   ###
    #
-- number pyramid --
    1
   121
  12321
 1234321
123454321

Checkpoint

shapes.odin compiles and, calling main, prints four shapes, a right triangle, an isoceles triangle, a diamond and a number pyramid, from four small procedures that all lean on one shared draw_row. You've turned a nested-loop pattern into something you can reuse and parameterise rather than retype.

Exercises

  1. Add a draw_hourglass procedure: a diamond, upside down (wide row first, narrowing to a point, then widening again would just be a diamond: an hourglass narrows to a point once, in the middle, then stops). Reuse draw_isoceles and draw_row rather than writing new loops from scratch.
  2. draw_triangle, draw_isoceles and draw_diamond all take a height: int and a ch: rune. What happens if you call draw_triangle(0, '*')? What about draw_triangle(-3, '*')? Try both, and explain why in terms of what 1 ..= height actually ranges over when height is 0 or negative.
  3. Extend draw_pyramid_of_numbers to accept a ch: rune parameter like the other shapes, printed between each pair of digits instead of nothing (so row 3 of a '-'-separated pyramid prints 1-2-1 instead of 121). (Solution sketch: write the separator inside the two digit loops, but only when you're not about to write the very last digit: an off-by-one is easy to introduce here, so check row 1's single-digit output still has no trailing separator.)
  4. draw_row's pad defaults to 0. What would break if you removed the := 0 and made pad a required parameter instead? Find every call site in shapes.odin that would need to change, and count them.
  5. Rewrite draw_isoceles to use a for loop's condition-only form (Chapter 2's second shape) instead of the range-based for row in 1 ..= height, and confirm the output is byte-for-byte identical. (Solution sketch: row := 1; for row <= height { ...; row += 1 }; the range form is just this pattern with the bookkeeping folded in, which is exactly why it's usually the nicer one to reach for.)

Further reading