Drawing shapes on a computer terminal

Antony Oduor - Jul 9 - - Dev Community

Have you ever seen cool shapes on a computer screen and wondered how they were made? It's simpler than you might think! We're going to explore how to create these shapes step by step, even if you're new to programming.

Prerequisites and Getting Started

Before we begin, you'll need a basic understanding of how to write a computer program. We'll be using a programming language called Go (or Golang), but don't worry if you've never used it before. The concepts we cover here can be applied to other similar languages like C or Java.

To follow along with the examples, you'll need to have Go installed on your computer. Here’s how you can set things up:

Setting Up Your Project

Open your terminal (that's the black screen where you type commands) and follow these steps:

  • Create a new folder for your project. Let's call it example-art.
mkdir example-art
cd example-art
Enter fullscreen mode Exit fullscreen mode
  • Initialize a new Go module in this folder.
go mod init example-art
Enter fullscreen mode Exit fullscreen mode
  • Create a new file named main.go inside this folder.
touch main.go
Enter fullscreen mode Exit fullscreen mode

Now, your example-art folder should contain two files: go.mod and main.go.

Writing Your First Program

Open main.go with a text editor (like Notepad or TextEdit) and paste the following code:

package main

func main() {
    // Your code will go here
}
Enter fullscreen mode Exit fullscreen mode
Running Your Program

Back in your terminal, type the following command to run your program:

go run .
Enter fullscreen mode Exit fullscreen mode

If everything is set up correctly, your terminal should show no errors.

Note: When you run the program, make sure to include the dot (.) after 'go run'

Now you're ready to start creating shapes on your computer terminal!

Simple Filled Rectangle

Imagine you want to create a filled rectangle like the one shown, with a specific width and height

******************** 
******************** 
******************** 
******************** 
******************** 
******************** 
Enter fullscreen mode Exit fullscreen mode

Instead of looking at it as a bunch of stars that span multiple lines, it will be easier if you looked at it as a grid which contains empty characters to begin with

a grid consisting of rows and columns

The grid is made up of rows which run from top to bottom.

grid rows without the columns

Did you notice the i == 2? In this article, all of our counting will begin at 0. We will therefore count like this: 0, 1, 2, 3, et cetera. In the figure above, i == 2 indicates the position of that row from the starting point. Hence the third row is at position 2. It is best to get used to this form of counting since it is quite commonly used within the computing world.

Each row is made up of a certain number of cells. While we are at this row, we can access its individual cells, each time updating the value with the desired character

a row depicting the columns

In essence, what we need to do is iterate through each row, and within each row, populate its cells with a specific character. In programming, we achieve this using a construct called repetition. Repetition allows us to repeat a statement multiple times without having to rewrite it each time. For instance, if we wanted to emphasise the line "I am learning to draw shapes on the terminal" by printing it one million times, instead of,

package main

func main() {
    println("I am learning to draw shapes on the terminal")
    println("I am learning to draw shapes on the terminal")
    println("I am learning to draw shapes on the terminal")
    println("I am learning to draw shapes on the terminal")
    println("I am learning to draw shapes on the terminal")
    println("I am learning to draw shapes on the terminal")
    /* 999994 more times of this */
}
Enter fullscreen mode Exit fullscreen mode

We could use repetition to accomplish this in a more compact way, as in,

package main

func main() {
    for i := 0; i < 1000000; i++ {
     println("I am learning to draw shapes on the terminal")
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, for i := 0; i < 1000000; i++ sets up a loop that repeats the println statement a million times, incrementing i from 0 to 999999.

nested for loops
Take for instance you wanted to print five stars in a single line. The for-loop below would accomplish this, without having to repeat the print(“*”) statement five times.

for in := 0; in < 5; in++ {
    print (*)
}
Enter fullscreen mode Exit fullscreen mode

The output of that program would be

*****
Enter fullscreen mode Exit fullscreen mode

Do you remember how we used loops to print a line multiple times? What if we treated the for loop above as a unit in the sense that we can also repeat it a certain number of times? In programming, there is a concept known as abstraction. Abstraction enables us to treat something as if it is a single unit. This will be akin to the concept of a dozen. If someone told you they have a dozen pencils, you would immediately know that they have twelve pencils. The same way to refer to a collection of things can be used in programming. Abstraction allows us to treat a whole block of code that does many things as if it was a single entity. We could essentially refer to the code above as the “inner loop”. If it is a unit, can we also repeat our “inner loop” multiple times?

// first 5 stars
for in := 0; in < 5; in++ {
    print (*)
}
// go to the next line
println()

// second 5 stars
for in := 0; in < 5; in++ {
    print (*)
}
// go to the next line
println()
// third 5 stars
for in := 0; in < 5; in++ {
    print (*)
}
Enter fullscreen mode Exit fullscreen mode

The code above will print 3 rows, each with five stars. The println() is used to move the cursor to the next line so that the next five lines are printed just below the first and not right next to them. We have met such inefficient code before when we needed to print a line a million times. In out case, if we also needed to print a million lines each with five stars, we will have to repeat our “inner loop” a million times. Can we make this more compact? This is where the notion of a unit comes in. We can treat that whole block of code as something that knows how to print five stars in a single line. We can then repeat it many times and each time, it will print the required five stars. But if we can use a for loop to repeat this statement, print(“I am learning to draw shapes”), can we also do the same to repeat the preceding block of code?

for out := 0; out < 4; out++ {
    for in := 0; in < 5; in++ {
        print(*)
    }
}
Enter fullscreen mode Exit fullscreen mode

You would notice that we are using a for loop to repeat the block of code we had before (in bold). Abstraction therefore enables us view a collection of things, like our “inner loop” as a single entity which we can easily repeat to produce whatever outcomes we need.

How does this relate to our grid system?
For grids or shapes, we use nested loops. The outer loop controls rows, and the inner loop controls cells or characters within each row. For example:
printing many rows image depiction

The complete code example

package main
func main() {
   height := 10                          // Number of rows
    width := 20                          // Number of columns
    for i := 0; i < height; i++ {        // outer loop for rows
     for j := 0; j < width; j++ {     // inner loop for columns
         print("*")      // print a character: *
     }
     println()           // Move to the next line after each row
    }
}
Enter fullscreen mode Exit fullscreen mode

In this combined loop structure:
The outer loop for i := 0; i < height; i++ iterates over each row.
The inner loop for j := 0; j < width; j++ prints a character (e.g., "*") for each column.
After printing all characters in a row, println() moves to the next line for the next row.
These loops allow us to efficiently create and manipulate grids or shapes in programming, scaling from simple patterns to complex structures, all while minimising repetitive code.

Introducing Constraints

Imagine you're creating a drawing using an empty rectangle, like this:

********************
*                  *
*                  *
*                  *
*                  *
********************
Enter fullscreen mode Exit fullscreen mode

When drawing each line in our grid, we follow specific rules, or "constraints," about where characters can appear. These rules ensure that characters only show up in certain places, particularly along the edges or borders of the drawing.

In our previous example, the constraint was effectively "no constraint." This meant we printed "*" in every position of the line. In contrast, our current example introduces a stricter constraint: characters can only be printed if they are within the vertical or horizontal borders.

constraints depicted as an image

Vertical Edges (constraints for i): Our outer loop index i correlates to the height (rows). Only draw characters at the very top and bottom lines. So, if we're at the first line (index 0) or the last line (index 5 in this case), we draw characters all the way across.

i == 0 || i == 5
Enter fullscreen mode Exit fullscreen mode

Horizontal Edges (constraints for j): Our inner index j correlates to the width (columns). Along each line, characters only go at the very left and right edges. For example, at position 0 and position 19 (since we have 20 columns in total).

j == 0 || j == 19
Enter fullscreen mode Exit fullscreen mode

Putting It All Together: To decide if we should draw a character at any position, we check these rules. If the position meets any of these conditions — being at the top or bottom line, or at the far left or right of any line — then we draw a "*" character. Otherwise, we leave that spot blank with a space (" ").

i == 0 || i == 5 || j == 0 || j == 19
Enter fullscreen mode Exit fullscreen mode

Complete code Example

The same principles from before still apply, however, when it comes to drawing the characters on each line, we need to introduce constraints as stated so that we can only print a character if a certain constraint is met. There are four constraints that we need the indexes i and j to adhere to before we print a character. We, therefore, need to print a non-empty character if and only if these constraints are met.

package main

func main() {
    rows := 6
    columns := 20


    for i := 0; i < rows; i++ {
        for j := 0; j < columns; j++ {
        // our constraints
            if i == 0 || i == (rows-1) || j == 0 || j == (columns-1) {
            // constraint met. 
            // print a non-empty character
                print("*")
            } else {
                // index not along the border
                // print an empty character
                print(" ")
            }
        }
        // move to the next row
        print("\n")
    }
}
Enter fullscreen mode Exit fullscreen mode

Overarching Idea

The following procedure can be replicated across different examples to produce any shape imaginable:

Outer: Loop the number of times as the lines you have.
    Inner: For each line,
    - If there is a constraint that makes that particular line appear different, take it into account.
    - Otherwise, draw the character in every position of the line
Enter fullscreen mode Exit fullscreen mode

finally

The next time you see a graphical shape on someone’s screen and wonder how it’s done, remember it often starts with simple loops. By understanding how loops and constraints work, you can unlock the potential to create visually interesting designs in your terminal. With practice and creativity, you can build more complex shapes, patterns, and even complete ASCII art masterpieces.

You can find an enhanced version of this code in a repository. While the repository will continue to evolve, the fundamental shapes mentioned here will always stay the same. Access the code at this link or visit https://github.com/oduortoni/console-art.git in your web browser.

. . .