My Intro
I am Tanishk, a first-year computer science student who just started classes about a month and a half ago. I already knew Python, but I had never touched C until my degree started. How much did I actually know about C when I began this project? Basic syntax, data types, loops, and... nothing else. Pointers? I had only heard terrifying stories about them (T-T).
Why am I making this project and why Doom?
So the thing is like this. 2 weeks down in college. They are teaching C. I felt like "Nah its no fun, its so dry. Its just doing things in more complex syntax than python." But it made me thinking... Why is C the backbone of every single operating system and anything that needs performance. I cant feel and see C properly. So that's why i decided the best way to learn it is by making something complex.
But what?
I remembered about watching the videos in YouTube about how wildly efficient Doom is and people run it anywhere even in calculators and displays of smart refrigerators. So i decided it would be fun to make a Doom engine and learn how it actually works and how they made it so optimized.
Why I removed standard C libraries?
Ummmmmmmmm..... cozz I am a masochist and want to know how the raw C works under the hood!
Summary: I want to learn C properly, so my weirdly curious masochist brain decided that building a Doom engine from scratch with zero standard libraries was a completely rational idea. 😊
(I won't mind if u call me weird)
Rules of this project
No standard C libraries. (No stdio.h, no math.h, no malloc). Everything must be raw or interact directly with the OS.
AI is a tutor, not a developer. I can use AI to explain complex concepts or syntax, but I cannot ask it to write code for me.
No copy-pasting. Even if I find the exact solution in a tutorial, documentation, or an AI explanation, I have to type it out myself so my brain actually processes the logic.
RTFM (Read The Fine Manual). I can read any documentation, Linux man pages, or watch any tutorials to figure out how the hardware and OS expect to be spoken to. (btw I didn't know what RTFM was, Gemini told me ✨)
The Problem: How to get my pixels on the screen
The print function
Before I can start drawing anything, I need to remake printf (T_T). How else am I going to debug?
To make a print function from scratch, I learned I need to use Linux syscalls and write inline assembly.
WTF!! Assembly? Wasn't this supposed to be pure C? There was no way I was learning Assembly—I remember a senior crying over it. So I ditched the project 💀. Just kidding.
Gemini gave me the inline assembly code, but I couldn't just use it. Rule #3, right? Besides, I couldn't understand anything in it. What do all these random di, a, and d even mean?
So, like a completely normal and sane person, I went straight to the [https://gcc.gnu.org/onlinedocs/gcc/Basic-Asm.html] to learn about it 😊.
There I learned the basic structure of extended inline assembly:
__asm__ asm-qualifiers(
"syscall"
: output operands
: input operands
: clobbers
);
But wait... that still didn't explain the random alphabets like a, d, di, and si.
I asked Gemini where I could read up on them and got directed to the Intel® 64 and IA-32 Architectures Software Developer’s Manual.
My first reaction: "What the hell... 5,000+ pages?! 💀 What secrets of the universe does this hold?" I tried to imagine it as a physical printed book. All I could picture was that if I dropped it from the top of my college building, it would create a massive crater (and maybe even a small earthquake 💀). Ahem...
Near the end of page 76 of this gigantic manual, I finally found it: "BASIC PROGRAM EXECUTION REGISTERS". Yayeee!
I learned that the random letters actually point to individual hardware registers inside the CPU. The names of the registers are slightly different depending on whether you are using a 32-bit or 64-bit system—specifically, the first letter changes (like RAX for 64-bit and EAX for 32-bit).
My understanding of how registers work goes something like this:
If I am the CPU, and the SSD is the entire college library, then the pile of books I collected from different sections to work with is the RAM and the Registers are the specific books actually open on my desk that I am actively reading right this second... I guess?
And here is how those alphabet constraints in GCC map to the 64-bit registers:
a = RAX
b = RBX
c = RCX
d = RDX
D = RDI
S = RSI
✨✨✨✨✨✨✨✨
(Btw I also found syscall at the pg 1987 of the manual but i couldn't understand anything 💀. So i relied on Gemini's explanation that its a phone call directly to the system HQ)
So now with the basics down, I understand my inline assembly and thus made my print function.
int len(const char *str){
int l=0;
while (str[l]!= 0)
l++;
return l;
}
void print(const char *str){
__asm__ volatile(
"syscall"
:
:"a" (1),
"D" (1),
"S" (str),
"d" (len(str))
:"rcx","r11","memory" //telling the computer that syscall will overwrite the memory in rcx and r11 so dont store anything important there
);
}
Ahhhhh one more question. How do I know what goes in each register. Ya Gemini told me, but I wasn't satisfied, so once again like a completely normal person I went to [https://blog.rchapman.org/posts/Linux_System_Call_Table_for_x86_64/] 😊😊
Well I spent 4 hrs of my life just to know how to print a line using syscall. why am i even doing this. It would be easier to jump of a cliff at this point... well whatever.
Now I can flex on my friends about how I can just write print in C like it's Python. Hahaha, who knows, I might even rizz up a girl with this! (Fantasies of an eternally single kid 💀)
Then i also made the function to print numbers
void printn(long int num){
int i = 0;
char l[30];
int is_neg = 0;
if (num < 0){
is_neg = 1;
num = -num;
}
if (num==0){
l[i]='0';
i++;
}
else{
while (num>0){
l[i] = num%10 + '0';
num = num/10;
i++;
}
}
char output[30];
int j = 0;
if (is_neg){
output[j]= '-';
j++;
}
while (i>0){
i--;
output[j]=l[i];
j++;
}
output[j]='\n';
j++;
output[j]='\0';
print(output);
}
Thus I created my first own header file
#ifndef UTILS_H
#define UTILS_H
int len(const char *str);
void print(const char *str);
void printn(long int num);
#endif
(Indeed Gemini told and explained me the format of the header file ✨ no i am not paid by google, I just have gemini pro for free that's why i use it a lot)
And yeah, since I am not using standard libraries, I couldn't just use int main(). I had to use void _start() as the entry point, and also use syscall 60 (sys_exit) to actually end the program gracefully.
Opening my Graphics card!
No, not literally. I learned that in the philosophy of Linux, everything is a file 🤌. Even your keyboard, your breakfast, and one day, even my cute virtual furry anime AI girlfriend (T-T).
So, I used Syscall 2 (sys_open) to open the graphics card file (/dev/dri/card1) on my computer and get my file descriptor (fd). What is it? It's basically a ticket or an ID for the file that the kernel checks to know exactly what hardware we are talking to.
int graphics_win(){ //opening the graphics card file
const char *path = "/dev/dri/card1";
int fd;
__asm__ volatile(
"syscall"
:"=a" (fd)
:"a" (2),
"D" (path),
"S" (2),
"d" (0)
:"rcx","r11","memory"
);
return fd;
}
Creating a buffer memory
Well, you might think, "how does this unhinged idiot guy know what to do after what?" Indeed, Gemini told me what to do next. You wouldn't find me writing this blog if I were actually trying to read raw Linux kernel documentation and header files. I would have already lost my sanity by that point 😭.
So now, I am telling my computer to reserve some memory in my VRAM so that we can work on it in the next steps.
int create_buffer(int fd, struct drm_mode_create_dumb *dumb){ // creating a buffer memory in the graphics card that does nothing
int result;
__asm__ volatile(
"syscall"
:"=a" (result)
:"a" (16),
"D" (fd),
"S" (0xC02064B2),
"d" (dumb)
:"rcx","r11","memory"
);
return result;
}
Nah don't ask me about the HEX code. Even Gemini quoted it as "Magic HEX code" . Gemini also said this stuff is calculated somehow based on struct sizes and read/write flags, so I figured it is some kind of forbidden knowledge and I shall stay far away to maintain my sanity.
Hmmm also now I am using Syscall 16(sys_ioctl) as we are doing read and write operation now.
Making ioctle wrapper
Well after a few Syscall 16s i realized I will need it a lot so i made a wrapper for it. I made it sometimes later in the project timeline but I am mentioning it early on here.
int wrapper(int fd, unsigned int hex_code, void *my_struct){
int result;
__asm__ volatile(
"syscall"
:"=a" (result)
:"a" (16),
"D" (fd),
"S" (hex_code),
"d" (my_struct)
:"rcx","r11","memory"
);
return result;
}
Maping the menmory
Now i am saying the computer to map the memory and any data sent to this memory shall be directed to the vram
And I also need to send the gpu handle with it. what it is? kind of like an ID of the dumb memory I created previously.
struct drm_mode_map_dumb map;
map.handle=dumb.handle;
map.pad = 0;
map.offset=0;
printn(wrapper(fd,0xC01064B3,&map));
what is offset? the pointer to the byte from where the mapping begins.
Making the display controller know it
Just doing Syscall 9(sys_mmap) with the stuffs we got
unsigned long map_ptr=map_buffer(dumb.size , fd , map.offset);
unsigned long map_buffer(unsigned long size, int fd, unsigned long long offset){ //making display controller recognize it
unsigned long result;
register long r10 __asm__("r10") = 1;
register long r9 __asm__("r9") = offset;
register long r8 __asm__("r8") = fd;
__asm__ volatile(
"syscall"
:"=a" (result)
:"a" (9),
"D" (0),
"S" (size),
"d" (3),
"r" (r10),
"r" (r9),
"r" (r8)
:"rcx","r11","memory"
);
return result;
}
Here we specially need to set the registers 8,9,10 as GCC inline assembly doesn't have simple letter constraints (like "a" or "D") for them.
Creating the Framebuffer
Framebuffer is something like... idk I dont understand myself. It does something with the resolution of the screen, bpp(how many bits per pixel) and pitch(how many bytes are in one row)
struct framebuffer fb;
fb.fb_id=0;
fb.width=1920;
fb.height=1080;
fb.pitch=dumb.pitch;
fb.bpp=32;
fb.depth=24;
fb.handle=dumb.handle;
int fb_status = wrapper(fd,0xC01C64AE, &fb); // create frame buffer
*Connecting to the moniter
Ya, ik the last few mins were very dull and technical, even i forgot how to smile. I want a hug (T-T)...NO NO!!! not form a boy! Ahem!
So now i need to find my moniter 🥰. Indeed its infront of me. But my computer doesn't know it sadly 💔. It knows that there are some connection to the moiter so i need to find the connection and say my prog to connect to it....aghghajhjhgakfkgksjvk why is it so complex
First i need to know about how many connections I have in my computer
struct drm_mode_card_res res;
res.fb_id_ptr = 0;
res.crtc_id_ptr = 0;
res.connector_id_ptr = 0;
res.encoder_id_ptr = 0;
res.count_fbs = 0;
res.count_crtcs = 0;
res.count_connectors = 0;
res.count_encoders = 0;
res.min_width = 0;
res.max_width = 0;
res.min_height = 0;
res.max_height = 0;
printn(wrapper(fd ,0xC04064A0, &res)); //get the info about the resources
Here, res.count_connectors now holds the number of physical connections my GPU has. Btw, if I don't set absolutely everything to 0 first, the kernel will throw a tantrum and return an error code (T-T).
Now we will change res.connector_id_ptr to point to an array of length res.count_connectors, and we will do a dual call (calling the exact same command a second time) to actually fetch the connector IDs.
unsigned int crtc_ids[res.count_crtcs];
unsigned int conn_ids[res.count_connectors];
res.crtc_id_ptr = (unsigned long long)crtc_ids;
res.connector_id_ptr = (unsigned long long)conn_ids;
res.count_fbs=0;
res.count_encoders=0;
well in the last run the computer placed some values in res.count_fbs and res.count_encoders so if i didn't change them to 0 then the computer would throw a tantrum on me again.(ik coz it happened to me 💀)
printn(wrapper(fd ,0xC04064A0, &res)); //duel call to get the connector IDs now
now we need to do trial and error to find at which ID the moniter is. If the connection is to the moniter then i will get a 1 or else 0
[struct drm_mode_get_connector connector;
connector.encoders_ptr = 0;
connector.modes_ptr = 0;
connector.props_ptr = 0;
connector.prop_values_ptr = 0;
connector.count_modes = 0;
connector.count_props = 0;
connector.count_encoders = 0;
connector.encoder_id = 0;
connector.connector_id = conn_ids[0];
connector.connector_type = 0;
connector.connector_type_id = 0;
connector.connection = 0;
connector.mm_width = 0;
connector.mm_height = 0;
connector.subpixel = 0;
connector.pad = 0;
printn(wrapper(fd ,0xC05064A7, &connector)); //connecting to the laptop screen](url)
For my case luckily it was in index 0
now we will just define the rules for the screen
struct drm_mode_modeinfo modes[connector.count_modes];
connector.modes_ptr = (unsigned long long)modes;
connector.count_props=0;
connector.count_encoders = 0;
wrapper(fd , 0xC05064A7 , &connector); //defining the mode or electric timing rules
(Btw, I had defined all these structs in my custom header file earlier!)
Drawing a square
Well, now we can finally draw something 😭 (tears of happiness). After all this, this part genuinely seems like the easiest step (T-T).
void draw_pixels(unsigned int *pixel_array, int x ,int y , unsigned int colour){
if (x < 1980 && x >= 0 && y >= 0 && y < 1080) {
pixel_array[(y*1920)+x] = colour;
}
}
unsigned int *pixels = (unsigned int *)map_ptr;
for(int y = 440; y< 640; y++){
for(int x=860; x < 1060;x++){
draw_pixels(pixels,x,y,0x00FFFF);
}
}
Indeed i used cyan coz i love Hatsune Miku. Even my terminal says that
Ok so now the last step ! fire up the crtc
We just need to configure the display controller and send it all the IDs we collected.
struct drm_mode_crtc crtc;
crtc.set_connectors_ptr = (unsigned long long)conn_ids;
crtc.count_connectors = 1;
crtc.crtc_id = crtc_ids[0];
crtc.fb_id = fb.fb_id;
crtc.x= 0;
crtc.y = 0;
crtc.gamma_size = 0;
crtc.mode_valid = 1;
crtc.mode = modes[0];
int crtc_status = wrapper(fd, 0xC06864A2 , &crtc);
printn(crtc_status);
And I asked it to wait for some time before it stops the program so that we can actually adore our square.
for(volatile int delay = 0; delay < 2000000000; delay++);
Now for the final run. Let's see the results!
Ahem ahem... it didn't run.
Because only one program at a time can act as the "DRM Master" and control the display. In my case, Wayland is already controlling it, so the kernel flat-out rejected my request.
So, I needed to drop into TTY mode by pressing Ctrl+Alt+Fn+F3. It’s like firing a low-level hardware interrupt where I am telling the Linux kernel: "Put the Wayland desktop to sleep and switch me to a raw text interface (TTY3)."
After logging into the terminal and entering ./engine (the name of my executable file)...
I get this:
YAYAEQEEEETYEUYWEWYFIWEGFWUGF FINALLY I got my screen and pixels on it! Bring out the champagne 🥂🎉🎉🎉.
(Fun fact: During testing, I once set that delay counter to 5 billion. My program never ended and I completely froze my PC. Why? Because I used a standard int for the counter, which maxes out at 2.14 billion. It ran out of bits, overflowed, and threw me into an infinite loop 💀).
Wait Wait u should be asking me how I took the screenshot
Beacuse there is no way to take screenshot in that new window.
So i just copied the pixels and stored them in a file then converted it into png
void screenshot(unsigned int *map) {
const char *path = "screenshot.raw";
int fd;
__asm__ volatile(
"syscall"
:"=a" (fd)
:"a" (2),
"D" (path),
"S" (577),
"d" (438)
:"rcx","r11","memory"
);
__asm__ volatile(
"syscall"
:
:"a" (1),
"D" (fd),
"S" (map),
"d" (1920*1080*4)
:"rcx","r11","memory"
);
__asm__ volatile(
"syscall"
:
:"a" (3),
"D" (fd)
:"rcx","r11","memory"
);
}
from PIL import Image
with open("screenshot.raw", "rb") as file:
raw_data = file.read()
image = Image.frombytes("RGB", (1920, 1080), raw_data, "raw", "BGRX")
image.save("screenshot.png")
print("Done")
Don't blame me for using python here. I asked Gemini he said its completely fine as its not a part of the actual engine. Please have some mercy on me 😭😭😭
Summary Workflow To Get My Own Screen
Opening the GPU file: Getting our access ticket (/dev/dri/card1).
Creating buffer memory in VRAM: Reserving a chunk of "dumb" memory in the graphics card that does nothing yet—just holding our place so we can work on it.
Mapping the memory: Telling the CPU that any data sent to this specific memory address should bypass the regular RAM and go straight to the GPU's VRAM.
Creating a framebuffer: Giving that raw memory some actual structure (width, height, and bits-per-pixel).
Getting resource info: Asking the GPU what physical hardware is connected to it.
The dual call for connector IDs: Now that we know how many connections there are from the first call, we call it a second time to store their actual IDs in an array.
Finding the laptop screen: Testing the IDs to find which one is actively connected to our monitor.
Defining the electric rules: Setting the display mode and timing rules for the screen.
Firing up the CRTC: Sending all these collected IDs to the display controller to finally load the actual screen.
This is all for this blog, and everything I accomplished in the project over the past month. It was much more difficult than using a pre-built engine that just renders things for you.
If I had used standard libraries, it would have taken way less time. If I had used a game engine, getting a square on the screen would have taken a few minutes—but I wouldn't actually know where it was coming from.
In my case, I know exactly where the pixels are coming from, and I know exactly why the screen is responding to me. It's hard, but the feeling is... beautiful.
What's Next & A Quick Heads-Up
Just a heads-up, my progress on this engine might be a bit slow. Between my brutal 3 to 3.5-hour daily local train commute to college, keeping up with my B.Tech coursework, assignments, and impending exams... plus just doing normal first-year student stuff (I deserve to have some fun too, T-T), I have very little free time left for personal projects.
Because of that, I will be treating this as a monthly devlog. Expect Part 2 to drop next month!
Also, this is my very first devlog, so there are definitely mistakes. Please correct me in the comments and drop suggestions on how I can improve my code or my writing.
Thanks for reading!
Link to the source code:
🔗 GitHub: https://github.com/MugenSama-01/bitshift-3d
If you enjoyed reading and want to connect with me:
💼 LinkedIn: www.linkedin.com/in/tanishk-roychowdhury-640779365
👾 Discord: unknown_identity_.


Top comments (0)