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, not0 ..< 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) keepsrowmatching 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 - 1andpad = height - rowboth fall straight out of "row 1 is the narrowest, rowheightis 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 arerunevalues, not one-character strings;'0' + u8(n % 10)mixes aruneand au8deliberately, 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.
5.6 A gallery
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
- Add a
draw_hourglassprocedure: 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). Reusedraw_isocelesanddraw_rowrather than writing new loops from scratch. draw_triangle,draw_isocelesanddraw_diamondall take aheight: intand ach: rune. What happens if you calldraw_triangle(0, '*')? What aboutdraw_triangle(-3, '*')? Try both, and explain why in terms of what1 ..= heightactually ranges over whenheightis 0 or negative.- Extend
draw_pyramid_of_numbersto accept ach: runeparameter like the other shapes, printed between each pair of digits instead of nothing (so row 3 of a'-'-separated pyramid prints1-2-1instead of121). (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.) draw_row'spaddefaults to0. What would break if you removed the:= 0and madepada required parameter instead? Find every call site inshapes.odinthat would need to change, and count them.- Rewrite
draw_isocelesto use aforloop's condition-only form (Chapter 2's second shape) instead of the range-basedfor 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
- Odin language overview,
forand procedures: https://odin-lang.org/docs/overview/ - Odin
core:strings(Builder): https://pkg.odin-lang.org/core/strings/