Building TUIs in a few lines of code with ncurses

Published: September 17, 2025 | Tags: #c #ncurses #tui #terminal

So, why does your “Hello, World!” look so simple but programs like vim and htop or classic roguelike games are so rich inside the terminal? It’s a matter of a powerful library called ncurses.

If you are into fancy GUIs and love React more than what you would admit, you can leave already… Are you gone? Okay, now to the real terminal purists, if you ever felt like writing an app that looks like a classic DOS program or a profesional system utility, you’re in the right place. Hey, the Standard C stdio.h is great, do not get me wrong. But we will find ourselves unable of moving the cursor, use colors (actually, we can, but that’s a secret for another time) or react to single key presses. We need a way to take full control of the terminal.

So… open your favorite code editor, because we will write some code.

A cat inside a computer.


But before that, some theory.

Why even using ncurses? Well, as we said earlier, when you use printf, the terminal is already in a mode with certain limitations. This is great for simplicity, as it handles everything for you. For example it buffers your input until you press Enter, it shows characters as you type them and scrolls automatically. Great in many cases!

But, what if we want raw control? ncurses is the way, turning the terminal into a grid of characters you can manipulate directly.

It provides a simple API to:

  • Move the cursor to any position on the screen.
  • Write text at any coordinate.
  • Use colors and text attributes (bold, underline, …).
  • Create windows or panels within the terminal.
  • Capture keyboard input instantly, char by char.

Yes, I know what you are thinking… We can make DOOM in the terminal with this. Maybe for a future article.


History time

The name ncurses literally means “new curses”.

It’s a free and open-source software library. A reimplementation of curses, which was written for BSD Unix.

And why did they make it? The goal of the curses library was to solve a big problem: in the 1980s, there were dozens of different physical computer terminals, and each had its own set of commands for controlling the screen.

curses provided a single and portable API so that a program like vi could run on any of them without being rewritten. You will notice that many software decisions have been made under the idea of compatibility.


Anatomy time

An ncurses program follows a specific structure.

You must always initialize the library at the start and clean up before your program exits. If you don’t you might find your terminal behaving very strangely.

The core components are:

  • Initialization and cleanup: You start with initscr() and end with endwin(). The classic. There are other functions like cbreak() (disable line buffering), noecho() (don’t print typed keys), and keypad(stdscr, TRUE) (enable arrow keys).

  • The screen (stdscr) and coordinates: we have a default"window called stdscr which represents your entire terminal. You draw on it using (y, x) coordinates, where y is the row and x is the column. Simple.

  • Printing and refreshing: Nothing you “draw” actually appears on the screen until you call refresh(). The most common way is mvprintw(y, x, "..."), to move the cursor and print in a single step.

  • The Main Loop and Input: Most ncurses applications have a loop that waits for user input with getch().


Time to build!

So after all these points, now we can get into three examples!


The movable “Hello, World!”

So, this is the “Hello, World!” of ncurses. Move with the arrow keys and quit with q.

#include <ncurses.h>
#include <string.h>

int main(void)
{
    initscr();              // Start ncurses mode.
    noecho();               // Don't echo user input.
    cbreak();               // Disable line buffering.
    curs_set(0);            // Hide the cursor.
    keypad(stdscr, TRUE);   // Enable special keys (arrow keys are special keys).

    // We get the dimensions of the terminal like this.
    int yMax, xMax;
    getmaxyx(stdscr, yMax, xMax);

    // Set up the message and its initial position.
    const char *message = "Hello, World!";
    int msg_len = strlen(message);
    int y = yMax / 2;
    int x = (xMax - msg_len) / 2;

    int ch;

    while (1)
    {
        mvprintw(y, x, message);

        ch = getch();

        // Exit loop
        if ('q' == ch)
        {
            break;
        }

        // We clear the old message, the number of spaces
        // is fixed... This could be dynamic but works for this.
        mvprintw(y, x, "             ");

        switch (ch)
        {
            case KEY_UP:
                y--;
                break;
            case KEY_DOWN:
                y++;
                break;
            case KEY_LEFT:
                x--;
                break;
            case KEY_RIGHT:
                x++;
                break;
        }

        // Boundary checking.
        if (y < 0)
        {
            y = 0;
        }

        if (y >= yMax)
        {
            y = yMax - 1;
        }

        if (x < 0)
        {
            x = 0;
        }

        if (x + msg_len >= xMax)
        {
            x = xMax - msg_len;
        }
    }

    endwin(); // The cleanup part.

    return 0;
}

To compile this, you would save it as a .c file and run gcc your_file.c -o output -lncurses.


Bouncing ball

In this example, instead of waiting for input, the ball will actually move by its own around the terminal. Press q to exit.

#include <ncurses.h>
#include <unistd.h> // For usleep().

#define DELAY 50000 // Delay in microseconds.

int main(void)
{
    initscr();
    noecho();
    curs_set(0);

    // To make getch() non-blocking
    nodelay(stdscr, TRUE);

    int yMax, xMax;
    getmaxyx(stdscr, yMax, xMax);

    // Initial position and direction.
    // Could be anything else.
    int y = yMax / 2;
    int x = xMax / 2;
    int next_y = 1;
    int next_x = 1;

    int ch;

    while (1)
    {
        if ('q' == (ch = getch()))
        {
            break;
        }

        clear();

        // We print our "ball" (for simplicity, just the char 'o').
        mvprintw(y, x, "o");

        refresh();

        usleep(DELAY);

        y += next_y;
        x += next_x;

        // If the ball hits the top or bottom, reverse its Y direction.
        if (y >= yMax - 1 || y <= 0)
        {
            next_y *= -1;
        }

        // Same but with the X direction.
        if (x >= xMax - 1 || x <= 0)
        {
            next_x *= -1;
        }
    }

    endwin();

    return 0;
}

Bouncing colorful ball!

Taking advantage of other functions part of this library, we can change the color of the ball easily!

#include <ncurses.h>
#include <unistd.h>

#define DELAY 50000

int main() {
    initscr();
    noecho();
    curs_set(0);
    nodelay(stdscr, TRUE);

    // Some color magic.
    // If we wanted to, we could go even lower and
    // set the colours ourselves with ANSI escape codes.
    // Maybe for another article.
    if (FALSE == has_colors())
    {
        endwin();
        printf("Your terminal does not support color\n");
        return 1;
    }

    start_color();
    // color pairs (ID, foreground, background).
    init_pair(1, COLOR_RED, COLOR_BLACK);
    init_pair(2, COLOR_GREEN, COLOR_BLACK);
    init_pair(3, COLOR_BLUE, COLOR_BLACK);
    init_pair(4, COLOR_CYAN, COLOR_BLACK);
    init_pair(5, COLOR_YELLOW, COLOR_BLACK);


    int yMax, xMax;
    getmaxyx(stdscr, yMax, xMax);

    int y = yMax / 2;
    int x = xMax / 2;
    int next_y = 1;
    int next_x = 1;
    int color_index = 1; // We start with red.

    int ch;

    while (1)
    {
        if ('q' == (ch = getch()))
        {
            break;
        }

        clear();

        // Turn on the color pair
        attron(COLOR_PAIR(color_index));

        mvprintw(y, x, "o");

        // Turn off the color pair
        attroff(COLOR_PAIR(color_index));

        refresh();
        usleep(DELAY);

        y += next_y;
        x += next_x;

        if (y >= yMax - 1 || y <= 0)
        {
            next_y *= -1;
            color_index++;
        }
        if (x >= xMax - 1 || x <= 0)
        {
            next_x *= -1;
            color_index++;
        }
        
        if (color_index > 5)
        {
            color_index = 1;
        }
    }

    endwin();

    return 0;
}

Conclusion

We could have explored more of the concepts, but this article is designed as an introduction. Check the resources online and read your man pages!

Now, go and build your nostalgic terminal application.