i like single-file builds for small projects

I’m not a big fan of C and C++ build systems. For anything that’s not really really large, I just put everything into one file. This is usually called a “unity build”. For most of my own projects a unity build is all I need, so I haven’t had to mess with build systems in quite a long time. I think many people waste time figuring out more complicated systems when a unity build will work great and let them focus on more important things. A unity build is a bit weird in some ways, and the issues it solves are relatively small, but on balance for small projects I like doing things this way.

basic unity builds in c

A “standard” C build involves compiling your .c files into .o files, and then linking them together into the final library or executable. Each of these C files is compiled as its own module. Declarations are used to make a module aware of functions that are implemented in other modules. Usually we group up declarations into .h files and #include them everywhere.

The way I do unity builds flips this on its head. I still split my program into separate C files, but I compile them all together in one unit. I’ll usually have a main.c file and then I’ll #include the other .c files directly into it. If pizza.c depends on functionality in cheese.c, I just put cheese.c first in the list of #includes. If there’s a circularity, I’ll add some forward declarations as needed.

My build command becomes one single compile step: cc -o foo foo.c. All of the other .c files are pulled in through the includes. I’ll throw that into a Justfile with all the warning command line args I like, and use pkg-config for any dependencies on the system.

a neat trick for test runners

I’ll add multiple executables to my build as a project grows. There are a couple of neat ways to add test runners to this setup.

In a test runner file you can #include all the same .c files, and either run tests defined alongside the code in each C file or define your tests separately in the test runner. You can also add a #ifndef TEST around main and then include main.c directly in your test file. The test file defines its own main, and also defines TEST to turn off the definition implemented in main.c. This saves you having to list out all of the C files in two places.

There is one downside to the unity build setup that I don’t love. Typically in C I like to use static extensively to hide implementation details within each C file. Each C file gets some private global state in static vars and has a small public interface with the rest of its functions declared static. That information hiding breaks down once you include everything into a single C file. In C++ projects anonymous namespaces break too. The unity build breaks intuition about things that are “file scoped”.

Another downside is that LSP servers are usually baffled by unity builds. Add it to the list of reasons I still prefer vim + ctags.

an example from the newdle solver

I’ve been working on a solver for newdle, which uses a unity build with a test runner.

    /* newdle.c */

    // I keep a prelude.h with all my usual stdlib includes, assert macro,
    // typedefs for shorter / clearer type names, common utility functions,
    // stuff I tend to reuse everywhere.
    #include "prelude.h"

    // Single-header utilities get included next, usually with a macro
    // to define implementations here too. These can drop into a more
    // traditional build nicely.
    #define ARENA_IMPL
    #include "arena.h"

    #define ARGS_IMPL
    #include "args.h"

    // I would include .c files here, but the newdle solver hasn't grown
    // large enough to bother splitting yet.

    ... most of the newdle code ...

    #ifndef TEST
    i32 main(i32 argc, str *argv)
    {
        
    }
    #endif

    /* tests.c */

    // The macro shuts off newdle.c's main function and includes everything.
    #define TEST
    #include "newdle.c"

    // ...and then the main here runs all the tests.
    # justfile
    # Build just builds both exes (my actual file does have tons of command
    # line args for sanitizers, warnings set up just the way I like, etc.)
    build:
        clang -Wall -o newdle newdle.c
        clang -Wall -o newdle_test tests.c

I’ve been trying out Zig lately, and I like how this approach works there too. The auto-generated build.zig template felt pretty verbose due to comments and trying to demonstrate things. It’s not too bad once you break it down, but I appreciate that I can use build-exe to do a simple unity build style there too.

I’m most comfortable starting a project with one file and a command to run it, instead of having to run a template generator to make a whole directory structure just to get started. Then once the program has grown a bit and it’s clear where the seams are between modules, you can easily rip stuff out and reimport it into the main file. For me it’s a very natural and lightweight way to work. Running a generator always feels heavy to me, even if the stuff it’s generating isn’t actually that big or complicated.

Changelog

Jul 29 2026 — published initial version