
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 todoFour 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.
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.
Eight loops of nine make 72, the byte for H. One more is I.
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.
| Cells | Holds |
|---|---|
| 0 | The trigger: . here makes a Unix call |
| 1 | Which operation, one of 21 |
| 2 to 13 | Three arguments, A, B and C, as 32-bit little-endian words |
| 14 to 17 | Reserved |
| 18 to 21 | The result, or 0xffffffff on error |
| 22 to 25 | errno, if it failed |
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.
- 00trigger · pointer
- 10op
- 2–50A
- 6–90B
- 10–130C
- 14–170reserved
- 18–210result
- 22–250errno
- 300data
Brainfuck
>++>+>>>>++++++++++++++++++++++++++++++>>>>+>>>>>>>>>>>>>>>>>>>>+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<.
C, from bfu.c
case '.':if (!ptr) unix_call();uint32_t a=word(2), b=word(6), c=word(10);switch (mem[1]) {case 2: /* write */if ((p=region(b,c))) result=write((int)a,p,c);put(18,(uint32_t)result);put(22,result<0?(uint32_t)errno:0);
$ ./bfu examples/write.bf
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.
| # | Operation | A | B | C |
|---|---|---|---|---|
| 1 | read | fd | buffer offset | max bytes |
| 2 | write | fd | buffer offset | bytes |
| 3 | open | path offset | flag bits | |
| 4 | close | fd | ||
| 5 | socket | |||
| 6 | bind | fd | IPv4 bytes offset | port |
| 7 | listen | fd | backlog | |
| 8 | accept | listening fd | ||
| 9 | connect | fd | IPv4 bytes offset | port |
| 10 | fsync | fd | ||
| 11 | rename | old path offset | new path offset | |
| 12 | lseek | fd | signed offset | whence |
| 13 | fork | |||
| 14 | execve | path offset | argv table offset | |
| 15 | waitpid | PID | status offset | |
| 16 | exit | code | ||
| 17 | receive timeout | socket fd | seconds | |
| 18 | reuse address | socket fd | ||
| 19 | dup2 | old fd | new fd | |
| 20 | pipe | 8-byte fd offset | ||
| 21 | dup | fd |
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.
Load
Drop every character that isn't one of the eight instructions, which is why the
.bffiles can carry comments in words. Fold runs like+++++into one instruction with a count, and match each[to its]ahead of time.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.
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.
- Codeserver.bfHTTP, todos, HTMLall on the tape
- CodeMailboxcells 0 to 25
- Localbfu.c7 KB of C
- 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.
| Decision | Why |
|---|---|
| One trap, no new instructions | A stock interpreter still parses the file. Only the meaning of a dot at one cell changes. |
| Arguments travel through the tape | The program fills the mailbox with the same + and > it uses for everything else. |
| Addresses are tape offsets | Brainfuck never holds a pointer, and every offset is range checked against the 64 KB tape. |
| Private operation numbers and flag bits | macOS and Linux number their calls and open() flags differently. One table lets one .bf file run on both. |
| Failures stay visible | A 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 safe | The 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
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.
| Program | Size | What runs in Brainfuck |
|---|---|---|
| server.bf | 39.7 MB | HTTP parsing, todo add, edit, toggle and delete, filters, HTML and CSS output, JSON, saved records |
| agent.bf | 40.6 MB | Calls a local model over HTTP, escapes and parses JSON, runs tools, keeps the transcript |
| harness.bf | 8.0 MB | Reads tool and final messages, forks, runs a shell command, waits, reports its status |
| client.bf | 37 KB | Connects 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.
- Localacceptop 8
- Localdup2socket becomes stdout
- CodeRead and routeone byte per op 1
- CodePrint the replyone dot per byte
- 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
- 0
accept(3)→ fd 1 - 3,758
dup2(1, 1)→ 1 - 5,902
receive timeout(1, 2s)→ 0 - 219,619,098
open("todos.tmp", flags 7)→ fd 5 - 219,651,420
write(5, tape+600, 512)→ 512 bytes - 219,658,280
fsync(5)→ 0 - 219,661,907
close(5)→ 0 - 219,662,744
rename("todos.tmp", "todos.db")→ 0 - 219,689,350
close(1)→ 0 - 219,691,049
close(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
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.
| Slot | Cell | Used | Done | Len | Title bytes |
|---|---|---|---|---|---|
| 0 | 600 | 0 | 0 | 0 | · |
| 1 | 664 | 0 | 0 | 0 | · |
| 2 | 728 | 0 | 0 | 0 | · |
| 3 | 792 | 0 | 0 | 0 | · |
| 4 | 856 | 0 | 0 | 0 | · |
| 5 | 920 | 0 | 0 | 0 | · |
| 6 | 984 | 0 | 0 | 0 | · |
| 7 | 1048 | 0 | 0 | 0 | · |
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.
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 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.
| Request | Bytes in | Steps |
|---|---|---|
| Add a todo | 155 | 219,693,613 |
| Edit a title | 164 | 241,168,968 |
| Toggle | 145 | 224,100,885 |
| Delete | 145 | 225,723,372 |
| Load the page, two todos | 68 | 83,893,913 |
| Load the page, empty | 68 | 60,002,421 |
| JSON API | 71 | 84,350,084 |
| 404 | 75 | 76,661,082 |
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
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.
- Codeagent.bfbuilds JSON
- LocalLocal modelport 8081T or F
- Localfork + execveoutput over a pipe
- 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
dup(1)→ 3, × 17→ "Print a greeting\n"
Transcript on the tape, cell 800
Print a greeting
Task:
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.
| Area | What the 19 tests check |
|---|---|
| Records | Add, edit, toggle and delete, then a restart to check they survive; eight-todo capacity; bad titles leave state unchanged |
| HTTP | Malformed forms and headers, bad routes, requests split across packets, lowercase headers |
| Output | HTML filtering and escaping, JSON escaping in both directions |
| Failure | A failed save rolls memory back; a truncated database fails without being overwritten; tape and bracket guards |
| Processes | The socket client, the harness running tools, tool stdin isolation, oversized messages rejected |
| Agent | Model, 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 2026 | Result |
|---|---|
| macOS ARM64, C11 with warnings as errors | Built, 19 tests passed |
| Linux ARM64, Alpine 3.22, GCC and musl | Built, 19 tests passed |
| Chrome, real form submissions | Add, toggle, edit and both filters passed |
| Browser page | No script tags, no runtime exceptions, no horizontal overflow at 1100 px |
| Mailbox write example | Printed the expected A |
| Agent against a fixed HTTP model fixture | Request, shell run, captured output and status, next request, final answer |
- Readable: C, ABI, walkthrough, README21 KB
- Brainfuck that runs88 MB
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.
| Option | What changes | What it costs |
|---|---|---|
| Smaller: put socat or inetd in front | The server becomes a filter, request on stdin, reply on stdout | Accepting 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 nothing | Programs depend on one OS's numbers and flags, and the range checks are gone |
| As built: 21 checked operations | The server uses about a dozen; the agent adds fork, execve, waitpid, pipe, dup and connect | lseek and exit never showed up in the traces, so they are the first to cut |
| Larger: a read_line helper in C | Most of the 82% spent on headers goes away | HTTP-shaped knowledge moves into C |
| Larger: poll or threads | More than one connection at a time | The 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 matters | What it looked like here |
|---|---|
| A boundary you can state in one sentence | Cell zero plus . is a Unix call; nothing else crosses |
| A written contract at that boundary | ABI.md: 21 operations, the cell layout, the error convention |
| Tests that check behaviour, not lines | Real processes, real HTTP, restarts and failed saves |
| Limits you can write down | 8 todos, 60 bytes each, 240 byte transcript, 8 turns |
| Small, readable code where it's dangerous | The 7 KB of C that touches the OS is plain and warning-free |
| A record of why | The 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
- One connection at a time, a two-second receive timeout, and no authentication or CSRF protection. Keep it on loopback.
- The agent runs model commands with your Unix permissions, so use a throwaway directory or a container.
- No TLS, no streaming and no real model provider. The request follows the Chat Completions shape but uses a one-letter text protocol instead of structured tool calls.
- A stock Brainfuck interpreter treats a dot at cell zero as a print, so these programs need the mailbox runtime.
- The browser UI is a server-rendered take on TodoMVC. It reloads after each action and has no double-click editing or Escape to cancel. Directory entries aren't synced, so there is no power-loss guarantee.
- The step counts come from a copy of the runtime that logs each call, checked against all 115 server calls. Why a region is slow is my reading of the source, not a measurement.
- The figures above are rebuilt from the repository's blog and four narrated explainer videos, which use the same trace.
Sources
- Abelson and Sussman, preface to the first edition ↗, Structure and Interpretation of Computer Programs, 1985.
- Peter Naur, Programming as Theory Building ↗, 1985.
- Andrej Karpathy on vibe coding ↗, 2 February 2025.
- Martin Fowler, Vibe Coding ↗.
- GitClear's 2025 AI Copilot Code Quality report ↗, as reported by DevClass, February 2025.
- Chat Completions API reference ↗, the request shape the agent follows.
- RealWorld ↗, the spec behind the Conduit example app.
