Brainfuck's eight characters, and the path a request takes: browser to socket over HTTP, socket to the C interpreter through libc, and the interpreter to the tape through mailbox cells 0 to 25. At cell 0 the period calls Unix instead of printing.

Systems and math

Does it even matter if we can't read the code? Writing a server in Brainfuck, a language made to be unreadable

219 million steps per todo

Four Brainfuck programs run on a 210-line C interpreter that only knows how to reach Unix. HTTP, JSON, saved records and the agent loop all happen on the tape, at about 219 million steps per todo.

Brainfuck has eight instructions, a row of byte cells and no way to open a file or a socket. It was made in 1993 to be hard to write and harder to read. A program can count, copy and compare on its tape, but the only things it can send out are single bytes to a screen.

I wanted to know how far up the stack it could go if I gave it the thinnest possible bridge to the operating system. So I built a 210-line C interpreter whose one extra trick is a door to Unix, and put everything else in Brainfuck. The result is a todo web app with saved records, and an agent loop that calls a language model and runs shell commands. All of their logic is Brainfuck, and 19 tests pass on macOS and Linux.

88 MB
of Brainfuck across four programs
7 KB
of C underneath, which knows only Unix
19
tests passing on macOS and Linux
219M
interpreter steps to add one todo (219,693,613)

Giving Brainfuck one door to Unix

A Brainfuck program sees a tape of cells and a pointer. > and < move the pointer, + and - change the cell, [ and ] loop while it isn't zero, and , and . read and write a byte. That is the whole language. A web server has to wait for a browser, read what it sent, open a file and reply, and the operating system controls all of that through system calls. Brainfuck has no instruction for making one.

00010203040506070809010011pointer
++++++++[>+++++++++<-]>.+.

A plain Brainfuck machine running in your browser: 8 instructions and a row of byte cells. The project's own runtime is C and adds Unix calls on top; this one only prints.

Program

Eight loops of nine make 72, the byte for H. One more is I.

Speed
0steps run
0pointer
+next instruction
stdout (nothing yet)

I changed the meaning of one instruction in one place. . at cell zero no longer prints. It runs the Unix call described by the cells next to it, a small mailbox, and writes the result back onto the tape. Anywhere else, . prints a byte as usual. The program is still valid Brainfuck; only the meaning of one cell changed.

CellsHolds
0The trigger: . here makes a Unix call
1Which operation, one of 21
2 to 13Three arguments, A, B and C, as 32-bit little-endian words
14 to 17Reserved
18 to 21The result, or 0xffffffff on error
22 to 25errno, if it failed
Brainfuck reaches Unix through 26 cells and nothing else.

The smallest complete example in the repository writes one letter. It sets operation 2, which is write, then file descriptor 1, a buffer at cell 30 and a length of one, puts 65 at cell 30, walks back to cell zero and prints. Step through it below, with the C that handles the last dot beside it.

examples/write.bf through runtime/bfu.c

Every cell starts at zero. The first 26 cells are the mailbox, and the program fills them with the same + and > it uses for everything else.

  1. 00trigger · pointer
  2. 10op
  3. 2–50A
  4. 6–90B
  5. 10–130C
  6. 14–170reserved
  7. 18–210result
  8. 22–250errno
  9. 300data

Brainfuck

  1. >++
  2. >+
  3. >>>>++++++++++++++++++++++++++++++
  4. >>>>+
  5. >>>>>>>>>>>>>>>>>>>>+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
  6. <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<.

C, from bfu.c

  1. case '.':
  2. if (!ptr) unix_call();
  3. uint32_t a=word(2), b=word(6), c=word(10);
  4. switch (mem[1]) {
  5. case 2: /* write */
  6. if ((p=region(b,c))) result=write((int)a,p,c);
  7. put(18,(uint32_t)result);
  8. put(22,result<0?(uint32_t)errno:0);

$ ./bfu examples/write.bf 

Step 1 of 10

Every call in every program follows that cycle: clear a few cells, add numbers into them, move to cell zero, print, read the answer. Only the number in cell 1 changes. There are 21 of them.

#OperationABC
1readfdbuffer offsetmax bytes
2writefdbuffer offsetbytes
3openpath offsetflag bits
4closefd
5socket
6bindfdIPv4 bytes offsetport
7listenfdbacklog
8acceptlistening fd
9connectfdIPv4 bytes offsetport
10fsyncfd
11renameold path offsetnew path offset
12lseekfdsigned offsetwhence
13fork
14execvepath offsetargv table offset
15waitpidPIDstatus offset
16exitcode
17receive timeoutsocket fdseconds
18reuse addresssocket fd
19dup2old fdnew fd
20pipe8-byte fd offset
21dupfd
The whole ABI, from ABI.md. Offsets are tape positions, never native addresses, and the numbers are the project's own, not Linux syscall numbers.

Keeping the C runtime small and ignorant

The bridge is where a project like this usually cheats. If the C parsed HTTP or stored records, the Brainfuck would be decoration. So the rule was that the runtime may move bytes between the tape and the kernel, and may not know what any of those bytes mean.

I kept runtime/bfu.c to 210 lines and three jobs. The interpreter loop is about 35 of them.

  1. Load

    Drop every character that isn't one of the eight instructions, which is why the .bf files can carry comments in words. Fold runs like +++++ into one instruction with a count, and match each [ to its ] ahead of time.

  2. Run

    Keep 65,536 zero-initialised byte cells. Arithmetic wraps at 256, and moving off either end of the tape or an unmatched bracket is an error.

  3. Call Unix

    When the program prints at cell zero, read the mailbox, run one libc call in a single switch, and write the result and errno back to cells 18 and 22.

  1. Codeserver.bfHTTP, todos, HTMLall on the tape
  2. CodeMailboxcells 0 to 25
  3. Localbfu.c7 KB of C
  4. OutputUnixsocket, file, fork

the C runtime has no idea what a todo, HTTP or JSON is

Every buffer or path the Brainfuck hands over passes one of two checks. A length that runs past the tape, or a path with no zero byte before the tape ends, gives EFAULT and the libc call never happens. That is what stops a wrong offset in a language with no types from becoming a bad pointer in C.

runtime/bfu.c, the two bounds checks

static void *region(uint32_t p, uint32_t n) {
    if (p>TAPE || n>TAPE-p) { errno=EFAULT; return NULL; }
    return mem+p;
}
static char *string_at(uint32_t p) {
    if (p>=TAPE || !memchr(mem+p, 0, TAPE-p)) {
        errno=EFAULT; return NULL;
    }
    return (char *)mem+p;
}

The other decisions in the runtime each answer one way a bridge like this could go wrong.

DecisionWhy
One trap, no new instructionsA stock interpreter still parses the file. Only the meaning of a dot at one cell changes.
Arguments travel through the tapeThe program fills the mailbox with the same + and > it uses for everything else.
Addresses are tape offsetsBrainfuck never holds a pointer, and every offset is range checked against the 64 KB tape.
Private operation numbers and flag bitsmacOS and Linux number their calls and open() flags differently. One table lets one .bf file run on both.
Failures stay visibleA failed call writes 0xffffffff and errno. Partial reads and writes are reported. SIGPIPE is ignored, so a browser that hangs up gives the program an error instead of killing it.
Folding runs is safeThe only effect outside the tape is a dot at cell zero, so merging +++++ into one step can't change what the outside world sees.

Serving a todo page from the tape

Every Unix call goes through the mailbox, so one request takes more than 200 million interpreter steps.

A web server is the hard case for a language with no I/O: it has to listen, accept, parse a protocol, keep state and write a reply, over and over without crashing. If the mailbox could carry that, it could carry the rest.

I wrote four programs on the same runtime. The server is the largest piece of work in Brainfuck, and the browser receives HTML forms and CSS that it generates byte by byte, with no JavaScript.

ProgramSizeWhat runs in Brainfuck
server.bf39.7 MBHTTP parsing, todo add, edit, toggle and delete, filters, HTML and CSS output, JSON, saved records
agent.bf40.6 MBCalls a local model over HTTP, escapes and parses JSON, runs tools, keeps the transcript
harness.bf8.0 MBReads tool and final messages, forks, runs a shell command, waits, reports its status
client.bf37 KBConnects to the server, builds a request, prints the reply

The page at the top of this post is plain forms, so server.bf writes every byte and the browser runs no script. In Chrome, add, toggle, edit and both filters passed with real form submissions.

If the mailbox could carry that, it could carry the rest.

The server first opens todos.db. On a first run that call returns -1 with ENOENT, and the list starts empty. Then it makes a socket, turns on address reuse, binds to loopback and listens. Its main loop accepts a connection and uses dup2 to make that connection standard output, so from then on an ordinary dot sends a byte to the browser.

  1. Localacceptop 8
  2. Localdup2socket becomes stdout
  3. CodeRead and routeone byte per op 1
  4. CodePrint the replyone dot per byte
  5. Localcloseop 4, twice

back to accept for the next browser

It reads the request one byte at a time, tracks the blank line that ends the headers, parses Content-Length, matches the route and percent-decodes the title. Pick a request below to see where its steps went and which Unix calls it made, at the step each one happened.

POST /add HTTP/1.1 · body title=Buy+milk
Bytes in
155
Status
303
Bytes out
77
Brainfuck steps
219,693,613

Steps by region of server.bf

  • 0. Mailbox and constant strings0
  • 1. Load the record database0
  • 2. Create socket, bind, listen0
  • 3. Accept, attach stdout to socket<1%547K
  • 4. Read HTTP headers, parse Content-Length82%181M
  • 5. Route matching and dispatch17%38M
  • 6. Write records to file, fsync, rename<1%46K
  • 7. HTML rendering with escaping and filtering<1%1K
  • 8. JSON encoding; also replies and close<1%25K

share of stepsshare of the 39.7 MB source

Unix calls, at step

  1. 0accept(3)→ fd 1
  2. 3,758dup2(1, 1)→ 1
  3. 5,902receive timeout(1, 2s)→ 0
  4. 219,619,098open("todos.tmp", flags 7)→ fd 5
  5. 219,651,420write(5, tape+600, 512)→ 512 bytes
  6. 219,658,280fsync(5)→ 0
  7. 219,661,907close(5)→ 0
  8. 219,662,744rename("todos.tmp", "todos.db")→ 0
  9. 219,689,350close(1)→ 0
  10. 219,691,049close(1)→ -1 EBADF

Between accept and the next call, 155 one-byte reads go through op 1. The reply goes out as 77 one-byte writes from ordinary dots. The second close fails with EBADF and the server carries on.

Rendering branches over the used and done flags, escapes quotes and angle brackets in each title, and prints a toggle form, an edit form and a delete form per todo. The headers carry a Content-Security-Policy that allows no scripts. The same records come out as JSON at /api, through a separate encoder region that escapes quotes and backslashes.

Storing eight todos in 512 bytes

The save path from the explainer: copy, write, fsync, rename, and the way back if a step fails.

A todo app that forgets everything on restart isn't much of an app, and SQLite was out because the C isn't allowed to know what a database is. The records had to live on the tape and reach the disk through the same 21 calls.

I gave the server eight slots of 64 bytes starting at cell 600. Each holds a used flag, a done flag, the title's byte length, sixty title bytes and a zero. The slot number is the todo's id, and a deleted slot gets reused. Step through the traced requests to see which bytes each one changes.

Live records at cell 600, before any request

Eight slots of 64 bytes: occupied flag, done flag, title length, 60 title bytes and a zero. The slot number is the todo's id.

SlotCellUsedDoneLenTitle bytes
0600000·
1664000·
2728000·
3792000·
4856000·
5920000·
6984000·
71048000·

A struck-through title is a freed slot whose bytes are still on the tape and in the file. Only the used flag decides whether a slot counts. Each change is then saved as all 512 bytes, to todos.tmp, fsync, and a rename over todos.db.

Step 1 of 8

Before a change, the program copies the 512 live bytes to a backup area at cell 1200. A save writes a temporary file, syncs it to disk and renames it over the old one, so a crash leaves either the old file or the new one, never half of each. If any step fails, the program copies the backup back, so memory and disk agree.

The rest of the tape has fixed homes too. The first 64 bytes of each request sit at cell 400, which is all the route matcher sees. The decoded title goes to cell 320, and cells from 100 on are scratch space for arithmetic.

Paying 219 million steps for one todo

Brainfuck has no way to jump to a position or compare two bytes in one step. Every comparison is a loop, and every move along the tape is one cell at a time, so the cost of a request shows up somewhere specific.

I ran the real server under a copy of the runtime that logged every Unix call, every printed byte and the step count in each region of the source, cut at the comments in the file. Adding “Buy milk” is a 155-byte request. It took 219,693,613 steps, 10 Unix calls besides the 155 one-byte reads, and 77 one-byte writes.

  • Read headers, parse Content-Length181.1M · 82%
  • Route, decode, update the record38.0M · 17%
  • Accept, attach stdout0.5M
  • Save: write, fsync, rename46K
  • Reply and close25K
The header parser is 440 KB of the 39.7 MB server source and spends 82% of the steps. The Unix calls themselves cost a few thousand steps each.

The cost per byte also climbs. The first gap between two reads is 282 thousand steps and the last ones are about 2.2 million. I haven't checked why. My guess is that the program walks further along the tape as the request grows, because it can't jump.

RequestBytes inSteps
Add a todo155219,693,613
Edit a title164241,168,968
Toggle145224,100,885
Delete145225,723,372
Load the page, two todos6883,893,913
Load the page, empty6860,002,421
JSON API7184,350,084
4047576,661,082
The header parser's cost grows with the bytes read, so longer requests cost more. The save itself is cheap.

The file sizes have a similar cause. The sources were expanded by a generator during development, so a move of twenty cells is written as twenty < characters. The first line of the server says “BRAIN compiled Unix todo server”, and then the file is mostly > for a very long way. The loader folds each run back into one instruction, so the size costs disk space and load time, not steps.

Running an agent loop from the tape

One traced run as a sequence diagram, from the explainer video.

An agent loop is a stranger test than a server. It has to be a client, speak JSON to a model, start other programs, capture what they print and remember the conversation, and all of that state has to fit on a tape with no pointers.

I pointed the same mailbox at a language model. agent.bf builds a Chat Completions request by hand, counts the escaped bytes for Content-Length, and posts it to a model running locally on port 8081. The model replies with one letter and some text: T means run this command, F means here is the final answer. For a T, the agent forks, runs the command through a pipe and adds the output to the transcript.

  1. Codeagent.bfbuilds JSON
  2. LocalLocal modelport 8081T or F
  3. Localfork + execveoutput over a pipe
  4. CodeTranscripton the tape240 bytes

until F, at most eight turns

Here is one traced run, against a test model that always answers the same way. The task is “Print a greeting”. Step through the calls, the bytes on the wire and what reaches the terminal.

Parent, pid 49243

The agent copies stdout to descriptor 3 so it can come back to it, prints “Task: ” and reads the task from stdin with commas, one byte at a time.

Unix calls from agent.bf

  1. dup(1)→ 3
  2. , × 17→ "Print a greeting\n"

Transcript on the tape, cell 800

  • Print a greeting
Task: 
Step 1 of 9

The transcript lives at cell 800 as 240 entries of four cells: a marker, a data byte, a carried byte and a scratch byte. Reading an entry means walking a counter along the tape and leaving breadcrumbs for the trip back, all in Brainfuck loops. The C has no operation for indexed memory and no idea what a message is.

A strange system is easier to test when every buffer has a size you can write down.

The limits are small on purpose: a 240-byte transcript, a 200-byte reply, eight turns and a 120-second receive timeout. A strange system is easier to test when every buffer has a size you can write down. Ordinary JSON escapes are decoded and \uXXXX is rejected. Tool stdin is /dev/null, so a child can't eat the agent's input.

harness.bf is the same tool loop with no model, for testing. Fed Tprintf hello, then Tprintf world; exit 7, then Fdone, it prints each wait status as four little-endian bytes in hex. 00070000 is a normal exit with code 7.

Testing code that nobody can read

Nobody reviews 88 MB of > and +. If the code can't be read, the only evidence that it works is what it does when you run it, so the tests had to start real processes and talk to them over real sockets.

I didn't type 88 MB of Brainfuck. I built this with an AI coding agent, and the generator that expanded the sources isn't in the repository. What's kept is the output, the ABI document, a short walkthrough, and the tests. The tests are what made me trust it.

AreaWhat the 19 tests check
RecordsAdd, edit, toggle and delete, then a restart to check they survive; eight-todo capacity; bad titles leave state unchanged
HTTPMalformed forms and headers, bad routes, requests split across packets, lowercase headers
OutputHTML filtering and escaping, JSON escaping in both directions
FailureA failed save rolls memory back; a truncated database fails without being overwritten; tape and bracket guards
ProcessesThe socket client, the harness running tools, tool stdin isolation, oversized messages rejected
AgentModel, tool, result and final answer against a fixed fixture; a bad HTTP status runs no tool; history over the limit is an error, not silent truncation
Check, 22 October 2026Result
macOS ARM64, C11 with warnings as errorsBuilt, 19 tests passed
Linux ARM64, Alpine 3.22, GCC and muslBuilt, 19 tests passed
Chrome, real form submissionsAdd, toggle, edit and both filters passed
Browser pageNo script tags, no runtime exceptions, no horizontal overflow at 1100 px
Mailbox write examplePrinted the expected A
Agent against a fixed HTTP model fixtureRequest, shell run, captured output and status, next request, final answer
From VALIDATION.md. CI also runs make test on every push and pull request. The fixture replaces inference only; no real model, API key or TLS was tested.
  • Readable: C, ABI, walkthrough, README21 KB
  • Brainfuck that runs88 MB
The part a person can check is 21 KB, about 0.02% of the bytes.

Sizing the C for a bigger app

The amount of C is a choice, and it decides where work lives. Too little and the interesting decisions leave Brainfuck for a wrapper script. Too much and the C starts to understand protocols, which is the thing this project says it doesn't do.

I read the operation table against the traces and asked what would change in each direction. None of this is built.

OptionWhat changesWhat it costs
Smaller: put socat or inetd in frontThe server becomes a filter, request on stdin, reply on stdoutAccepting and file handling move to a script, so when to fsync and rename leaves Brainfuck
Smaller: one raw syscall(number, A, B, C)The C shrinks to almost nothingPrograms depend on one OS's numbers and flags, and the range checks are gone
As built: 21 checked operationsThe server uses about a dozen; the agent adds fork, execve, waitpid, pipe, dup and connectlseek and exit never showed up in the traces, so they are the first to cut
Larger: a read_line helper in CMost of the 82% spent on headers goes awayHTTP-shaped knowledge moves into C
Larger: poll or threadsMore than one connection at a timeThe C starts owning scheduling

The 512-byte file is region 6 of the server, and it exists because Brainfuck has to rebuild what a database already does. There are two ways to swap in SQLite.

A: sqlite3 as a child process

  • Reuse the agent's fork, pipe and execve, and talk to sqlite3 -json over its stdin and stdout
  • No change to the C
  • Brainfuck builds the SQL text, so it has to double every quote in a title itself, and one miss is SQL injection

B: SQLite inside bfu.c

  • About eight new operations: open, prepare, bind, step, read a column, finalize
  • Handles are small integers, and the reserved cells 14 to 17 could carry a fourth argument
  • Bound parameters remove the escaping problem, but the C now contains a database

I would build A first, because it leaves the C alone and so tests the project's claim. The same reasoning applies to a larger target such as Conduit, the RealWorld example backend with 19 endpoints. Routing and JSON bodies would stay in Brainfuck, tables and joins would go to SQLite, and password hashing to a Unix tool. The 64 KB tape and one connection at a time would be the first limits it hit, and speed is the open question: a real browser sends several times more header bytes than the test client.

Asking whether readable code still matters

The line every programmer gets taught comes from the preface to Structure and Interpretation of Computer Programs: “Programs must be written for people to read, and only incidentally for machines to execute.” This project is the opposite case. It was written for a machine, by a machine, and a person can only check it from the outside.

I built this the way a lot of code gets made now. Andrej Karpathy named it vibe coding in February 2025: you describe what you want, accept what comes back, and “forget that the code even exists”. Martin Fowler's answer was that this suits throwaway software and not much else. The data so far leans his way. GitClear looked at 211 million changed lines from 2020 to 2024. In 2024, blocks of five or more duplicated lines were eight times as common as before, moved code fell by 40%, and for the first time copy-pasted lines outnumbered moved ones. Moving code is what refactoring looks like in a diff, so less of it means less tidying up. GitClear sells code analysis and most of its data comes from its own customers, so I read it as a strong signal, not a census.

When people wrote the code

  • Readable code let the next person change it
  • The code was the main record of what the program does
  • Review meant reading the diff

When a model writes it

  • The model rewrites as fast as it reads
  • The tests, the spec and the interface carry the meaning
  • Review means checking the behaviour

the same job, done by different things

Peter Naur argued in 1985 that a program isn't really its text. It is a theory in the heads of the people who built it: why it is shaped this way, what each part is for, what would break if you changed it. The code is a lossy record of that theory. When the team leaves, the theory goes with them, and the code alone can't bring it back.

Readability still matters, but it has moved.

Read that way, this project shows where the theory lives once the code stops being readable, and the 88 MB holds none of it. ABI.md fixes the one contract between the tape and Unix, the walkthrough shows how a copy or a clear looks in Brainfuck, and the 19 tests say what has to stay true. I could throw the Brainfuck away and regenerate it, and I'd know whether the new one was right, because those three things would tell me.

My answer after this: readability still matters, but it has moved. The lines inside a function matter less, because the thing writing and rereading them is a model that doesn't get tired. What matters more is the part a person has to hold in their head to know whether the system is right.

Still mattersWhat it looked like here
A boundary you can state in one sentenceCell zero plus . is a Unix call; nothing else crosses
A written contract at that boundaryABI.md: 21 operations, the cell layout, the error convention
Tests that check behaviour, not linesReal processes, real HTTP, restarts and failed saves
Limits you can write down8 todos, 60 bytes each, 240 byte transcript, 8 turns
Small, readable code where it's dangerousThe 7 KB of C that touches the OS is plain and warning-free
A record of whyThe walkthrough and this post; the generator's output alone says nothing

The one thing I'd change is the missing generator. The readable source of the Brainfuck is the closest thing this project had to Naur's theory in code form, and it wasn't kept. If a model can regenerate the output, the input is what deserves to be readable.

Notes

Sources

  1. Abelson and Sussman, preface to the first edition ↗, Structure and Interpretation of Computer Programs, 1985.
  2. Peter Naur, Programming as Theory Building ↗, 1985.
  3. Andrej Karpathy on vibe coding ↗, 2 February 2025.
  4. Martin Fowler, Vibe Coding ↗.
  5. GitClear's 2025 AI Copilot Code Quality report ↗, as reported by DevClass, February 2025.
  6. Chat Completions API reference ↗, the request shape the agent follows.
  7. RealWorld ↗, the spec behind the Conduit example app.

More projects