We wrote an HTTP server from scratch in x86-64 assembly and used it to display a live rotating 3D ASCII cube, built with linear algebra and a projection matrix.

Inspiration

We wanted to build something bare-bones, from scratch, to actually learn how the layers under a normal web stack work — not another app sitting on top of Express or Flask. So we picked the hardest possible foundation: an HTTP server with no libc, no frameworks, no libraries standing between our code and the Linux kernel. Just raw x86-64 syscalls. Once that constraint was set, we wanted something visually alive to prove it actually worked in real time, not just a curl response full of text — so we built a rotating 3D ASCII cube, rendered server-side with real linear algebra, and streamed it to a live terminal client.

What it does

A web server, written entirely in x86-64 assembly, listens on port 8888 and speaks HTTP directly through raw syscalls (socket, bind, listen, accept, fork) — no sockets library, no HTTP library. It accepts GET requests encoding three parameters — $\theta$, $\phi$, and $d$ (distance) — computes a rotation and perspective projection of a 3D cube using that state, and serves back a $160 \times 160$ ASCII frame. A separate C client, built on ncurses for the terminal UI and libcurl for the HTTP transport, polls the server on an interval, animates the rotation by incrementing $\theta$ each tick, and renders each returned frame live in the terminal — so you watch a cube spin, entirely reconstructed frame-by-frame over real HTTP, with nothing but assembly on one end and raw syscalls plus curl on the other.

How we built it

We split the project into three layers, each owned by a different piece of the stack.

The HTTP server (Gabriel) — hand-written in x86-64 assembly. No libc: socket setup, binding, listening, and accepting connections are all raw Linux syscalls (socket = 41, bind = 49, listen = 50, accept = 43). Each accepted connection is handled by forking (fork = 57) — the parent immediately closes its copy of the client socket and loops back to accept, while the child closes the listening socket and handles the request, giving us simple concurrent connection handling without a threading library. Incoming requests are parsed byte-by-byte (repe cmpsb) to confirm they're a GET, then the request line is parsed directly for three floating-point parameters using strtod, called straight from assembly into a C runtime function via the standard x86-64 calling convention (xmm0xmm2 for the doubles, rdi/rsi for pointers).

The cube math (Dillon) — the rotation and projection logic, written in C++, wired into the assembly server as getFrame(theta, phi, distance), works entirely in 4D homogeneous coordinates so that rotation, translation, and projection can all be expressed as single matrices. Rather than rotating the cube around two separate axis-aligned axes, the cube is spun about its own main diagonal, $\hat{n} = \frac{1}{\sqrt{3}}(1, 1, 1)$, using Rodrigues' rotation formula collapsed into one explicit $4\times4$ matrix, with a shared $\tfrac{1}{3}$ factor pulled out front (a consequence of every component of $\hat n$ being $\tfrac{1}{\sqrt3}$):

$$R(\theta) = \frac{1}{3}\begin{pmatrix} 2\cos\theta + 1 & (1-\cos\theta) - \sqrt{3}\sin\theta & (1-\cos\theta) + \sqrt{3}\sin\theta & 0 \\ (1-\cos\theta) + \sqrt{3}\sin\theta & 2\cos\theta + 1 & (1-\cos\theta) - \sqrt{3}\sin\theta & 0 \\ (1-\cos\theta) - \sqrt{3}\sin\theta & (1-\cos\theta) + \sqrt{3}\sin\theta & 2\cos\theta + 1 & 0 \\ 0 & 0 & 0 & 3 \end{pmatrix}$$

(the bottom-right entry carries its own factor of $3$ so that, after the leading $\tfrac13$, the homogeneous coordinate is left as exactly $1$). Each vertex $\mathbf{v} = (x,y,z,1)^\top$ of the cube is rotated as $\mathbf{v}' = R(\theta)\, \mathbf{v}$.

After rotating, every vertex is translated $+3$ along $z$, $\mathbf{v}'' = T\, \mathbf{v}'$ with

$$T = \begin{pmatrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & 1 & 3 \\ 0 & 0 & 0 & 1 \end{pmatrix}$$

which pushes the whole cube out along the viewing axis so that it sits in front of the camera rather than straddling $z=0$ — necessary for the perspective projection below to behave correctly.

The translated vertex is then projected from 3D into 2D using a perspective projection matrix built from the distance parameter $d$:

$$P = \big( \; \mathbf{e}_1 \;\; \mathbf{e}_2 \;\; (0,0,1,\tfrac{1}{d})^\top \;\; \mathbf{0} \; \big) = \begin{pmatrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & \tfrac{1}{d} & 0 \end{pmatrix}$$

Multiplying a homogeneous vertex by $P$ leaves $x$ and $y$ unchanged but turns the fourth (homogeneous) coordinate into $z/d$; dividing $x$ and $y$ by that term performs the perspective divide, so points farther from the camera shrink toward the center, controlled by $d$.

A second, independent rotation by $\phi$ is applied in the 2D projected plane, using the standard 2D rotation matrix embedded in the top-left corner of a $4 \times 4$ identity matrix:

$$R_{2D}(\phi) = \begin{pmatrix} \cos\phi & -\sin\phi & 0 & 0 \\ \sin\phi & \cos\phi & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}$$

From there a viewport transform maps the projected, rotated coordinates onto the discrete $160 \times 160$ character grid. Each grid cell is classified into one of three states — $0$ for empty space, $1$ for an edge of the cube, and $2$ for a vertex — producing a tristate matrix the same shape as the output grid. A small, illustrative corner of that matrix, showing a vertex with two edges running away from it through otherwise empty space, looks like this:

$$\begin{pmatrix} 2 & 1 & 1 & 1 & 0 & 0 \\ 1 & 0 & 0 & 0 & 1 & 0 \\ 1 & 0 & 0 & 0 & 0 & 1 \\ 1 & 0 & 0 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 & 0 & 0 \\ 0 & 0 & 1 & 0 & 0 & 0 \end{pmatrix}$$

(Illustrative only — not an actual rendered frame.) Each entry of this matrix corresponds one-to-one with a character cell in the final output: a $2$ becomes the character marking a cube vertex, a $1$ becomes an edge character, and a $0$ becomes blank space, together producing the ASCII frame the server sends back.

The terminal client (Adam, networking wired in by Gabriel) — a C program using ncurses for the live terminal display and libcurl's easy interface for HTTP. The client uses curl_easy_setopt to register a custom write callback (CURLOPT_WRITEFUNCTION) and a growable buffer (CURLOPT_WRITEDATA), reused across a single long-lived CURL handle so the TCP connection stays warm between frame requests instead of reconnecting every tick. Each animation frame calls GET_FRAME(easy_handle, theta, phi, distance), which builds the request URL, performs a blocking curl_easy_perform, and hands the accumulated response buffer back to be rendered with mvprintw.

Challenges we ran into

  • No standard library, no safety net. Writing HTTP parsing, socket setup, and string comparison directly in assembly meant reimplementing things a framework normally gives you for free, with none of the usual debugging tools like stack traces — just registers and raw memory.
  • HTTP framing mismatches between the assembly server and a real HTTP client. libcurl always sends a request line with a leading / before the path (GET /0.5,1.2,10.0 HTTP/1.1), but our assembly parser originally jumped straight into parsing $\theta$ right after "GET ", with no accounting for that leading slash — a mismatch we only caught by tracing through the raw assembly parsing logic byte by byte.
  • Successful responses were missing their HTTP status line. Our error path correctly sent a full HTTP/1.0 404 Not Found header, but the success path just wrote the raw frame bytes straight to the socket with no HTTP/1.1 200 OK preamble — invisible when testing with curl directly, but a real problem for libcurl, which expects valid HTTP framing to parse a response at all.
  • A single-character CURLOPT_WRITEFUNCTION / CURLOPT_WRITEDATA mixup. Early on, the buffer pointer meant for CURLOPT_WRITEDATA was accidentally passed to CURLOPT_WRITEFUNCTION instead, silently breaking the client's ability to capture the response into memory — a good reminder that with C and raw pointers, the compiler won't always save you from a wrong-but-valid-looking line.
  • Debugging around ncurses. ncurses takes over the entire terminal display, which meant printf/fprintf debug output written before initscr() would flash and vanish the instant the UI initialized — we had to redirect output to a file to actually read our own debug logs.
  • Coordinating three people editing the same low-level system without a shared git remote, which led to real merge conflicts between diverged local copies of the client code that had to be manually reconciled.

What we learned

How an HTTP server actually works when you strip every abstraction away — sockets, syscalls, connection handling via fork, and manual request parsing, all without a framework to hide the details. We got hands-on with x86-64 calling conventions and floating-point register passing (xmm0xmm2) calling from assembly into C. And on the graphics side, real 3D rotation and projection math, taken from equations on a page to live ASCII frames streamed over a socket we built ourselves.

What's next

Right now the server handles one stateless request at a time: a client asks for a frame, gets a frame, and the server forgets it ever happened. The natural next step is to make it stateful and multi-client at once — we want to use the same hand-written assembly server to host a small multiplayer game, with multiple terminal clients connected simultaneously, each sending input and receiving a shared, synchronized view of the world back over HTTP. The fork-per-connection model we already have gives us concurrent connection handling for free; the real work ahead is introducing shared state across those forked children — something we deliberately avoided needing for a single rotating cube, but can't avoid once multiple players need to see the same game world.

Built With

  • assembly
  • c
  • c++
  • curl
  • eigen
  • linear-algebra
  • ncurl
  • x86-64
Share this project:

Updates