The first thing we want to set up is a monochrome 64x32 screen. We will use the terminal for this since it's lightweight and a good way to learn how terminals work.

Initialize project

Create a new directory called chip8, enter it and initialize the project with zig:

$ mkdir chip8
$ cd chip8
$ zig init-exe
info: Created build.zig
info: Created src/main.zig
info: Next, try `zig build --help` or `zig build run`

Terminal abstraction

To be able to manipulate the terminal, we need to modify some attributes like turning off echoing, canonical mode. This is done by using std.os.tcgetattr and std.os.tcsetattr. By turning off echoing, we disable seeing keypresses from the user. Disabling canonical mode changes reading mode from line-by-line to byte-by-byte.

Disable echoing and reading line-by-line

Create a file in src/Terminal.zig. We'll start by creating a init function to turn off echoing and canonical mode:

const std = @import("std");
const os = std.os;

const ICANON: u32 = 1 << 1;
const ECHO: u32 = 1 << 3;

pub fn init() !void {
    var termios = try os.tcgetattr(os.STDIN_FILENO);
    termios.lflag &= ~(ECHO | ICANON);
    try os.tcsetattr(os.STDIN_FILENO, os.TCSA.FLUSH, termios);
}

To disable echoing and canonical mode we use the tilde operator which flips ones and zeroes. ~(ECHO | ICANON) becomes ~(0b1010) which is (0b0101). This keeps the old flags while also setting ECHO and ICANON to zero.

Lets replace src/main.zig to see this in action:

const Terminal = @import("Terminal.zig");

pub fn main() !void {
    try Terminal.init();
    while (true) {}
}

Clearing and writing to the screen

After running with zig build run, we see that keypresses aren't displayed anymore. This is not quite interesting however, so lets create a 64x32 screen that we can write to the terminal. We need a clear function and a write function to achieve this. In src/Terminal.zig, lets create the write function:

const stdout = std.io.getStdOut().writer();

pub fn write(bytes: []const u8) !void {
    try stdout.writeAll(bytes);
}

In the clear function we're gonna write an escape sequence to the terminal. Escape sequences always start with the escape character 27, followed by a [ character. Escape sequences instruct the terminal to do various text formatting tasks, such as coloring text, moving the cursor around and clearing the screen.

The command for clearing the screen is \x1B[2J. \x1B is the escape sequence, [ is the delimiter, and 2J is the clear command. Lets add this to src/Terminal.zig:

pub fn clear() !void {
    try write("\x1B[2J");
}

After clearing we also want to reposition the cursor at the top-left corner. This is done withe the command \x1B[<row>;<column>H. The default with \x1B[H is to move the cursor to top-left, so lets use that:

pub fn clear() !void {
    try write("\x1B[2J\x1B[H");
}

Back in src/main.zig, lets define our 64x32 screen at the top:

var screen: [64 * 32]u1 = undefined;

If the value is 1, we want █ on the screen. Inside main, lets clear the terminal, modify the screen then show it:

pub fn main() !void {
    try Terminal.init();

    // Clear the terminal
    try Terminal.clear();

    // Modify screen
    for (screen) |*bit, i| {
        if ((i + 1) % 2 == 0) bit.* = 1;
    }

    // Show in terminal
    for (screen) |bit, i| {
        if (bit == 1) try Terminal.write("█") else try Terminal.write(" ");
        if ((i + 1) % 64 == 0) try Terminal.write("\n");
    }

    while (true) {}
}

Hiding the cursor

After running this, you should see the columns alternating between █ and space. However, we can still see the cursor. Lets add this last thing to src/Terminal.zig:

fn hideCursor() !void {
    try write("\x1b[?25l");
}

Call this function after Terminal.init() to finish this part:

pub fn main() !void {
    try Terminal.init();
    try Terminal.hideCursor();
terminal.webp
Figure 1: Terminal that shows screen
os
├── src
│   ├── main.zig
│   └── Terminal.zig
└── build.zig