Introduction
Ever wondered if you could run a Minecraft server on a $1 microcontroller ?
I did. And the answer is yes. Literally.
There's a project called Bareiron, by p2r3, and it's probably one of the most fascinating things I've seen in the Minecraft open-source world in years. A binary that fits in 300 KB, 6800 lines of C, zero external dependencies, no malloc, no threading, running on an ESP32 that costs a single dollar.

Infinite terrain generation. Biomes. Caves. Crafting. Mining. Mobs. Hunger. Chests. Everything you'd expect from a survival server.
On a chip drawing 0.5 Watts clocked at 160 MHz.
To put things in perspective : a vanilla Minecraft server needs gigabytes of RAM. The ESP32-C3 has 520 KB of SRAM (about 400 KB available after boot). Processors from 20 years ago already ran at gigahertz speeds -- this one tops out at 160 MHz. That's roughly a 20,000x gap in raw power.
So how is this possible ? p2r3 didn't write a Minecraft server in C -- he reinvented every single component so it fits within these constraints. Let me show you the actual code.

The brain of the project : terrain generation without memory
The biggest challenge when building an embedded Minecraft server is terrain generation.
In vanilla Minecraft, the world is generated using Perlin noise : multiple layered octaves, 6 biome parameters (temperature, humidity, continentalness, erosion, weirdness, depth), and a whole caching system to avoid regenerating chunks.
The result is gorgeous. Expensive in compute, memory-hungry.
Bareiron's approach is radically different. Instead of stacking noise, it uses bilinear interpolation on 4 points generated by a deterministic RNG.
You know when you upscale a pixelated picture and the edges go blurry ? That's exactly this.
// worldgen.c, lines 117-171 (simplified)
uint8_t interpolate (uint8_t a, uint8_t b, uint8_t c, uint8_t d, int x, int z) {
uint16_t top = a * (CHUNK_SIZE - x) + b * x;
uint16_t bottom = c * (CHUNK_SIZE - x) + d * x;
return (top * (CHUNK_SIZE - z) + bottom * z) / (CHUNK_SIZE * CHUNK_SIZE);
}
uint8_t getHeightAt (int x, int z) {
int _x = floor(x / CHUNK_SIZE);
int _z = floor(z / CHUNK_SIZE);
int rx = x % CHUNK_SIZE;
int rz = z % CHUNK_SIZE;
uint32_t hash = getChunkHash(_x, _z);
uint8_t biome = getChunkBiome(_x, _z);
return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}
Standard bilinear interpolation : 4 corners, weights from position, one uint8_t output. CHUNK_SIZE is 8, so it's all integer multiplication, no floats.
p2r3 walks through it step by step in the video : first the 4 chunk corners, each with a height seeded by the RNG.

Then interpolation between those 4 points creates a continuous surface.

And repeating the pattern across adjacent chunks produces infinite terrain.

The deterministic RNG
The key that makes this possible is the seeding. Every corner of every chunk needs a unique, reproducible pseudo-random value.
// worldgen.c, lines 13-22
uint32_t getChunkHash (short x, short z) {
uint8_t buf[8];
memcpy(buf, &x, 2); // 16 bits of X coordinate
memcpy(buf + 2, &z, 2); // 16 bits of Z coordinate
memcpy(buf + 4, &world_seed, 4); // 32 bits of global seed
return splitmix64(*((uint64_t *)buf));
}
It packs 16 bits of X, 16 bits of Z, and 32 bits of seed into an 8-byte buffer, runs it through splitmix64. Result : a deterministic unique value for every position, based on world seed.
The server doesn't need to store terrain. It recomputes on the fly when a player moves into a new area, getting the exact same result every time.
splitmix64 is the PRNG powering the hashing -- a fast 64-bit mixer designed for integer hashing :
// worldgen.c (simplified)
static uint32_t splitmix64 (uint64_t state) {
state += 0x9E3779B97F4A7C15ull;
uint64_t z = state;
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ull;
z = (z ^ (z >> 27)) * 0x94D049BB133111EBull;
return (z ^ (z >> 31)) >> 32;
}
3 operations : addition, xor/shift, multiply, xor/shift, multiply, xor/shift. No lookup tables, no loops. It takes the 8-byte buffer (X + Z + seed), treats it as a 64-bit integer, and returns 32 bits of hash. Deterministic, fast, 5 lines.
Why this isn't Perlin noise
p2r3 explains : "the more digits of the random number you add, the more regular the terrain becomes, like more coin flips approaching 50/50". Each biome chooses how many bit extractions to combine :
// worldgen.c, lines 51-115
// For plains biome : 4 combined factors → regular terrain
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// For snowy plains : 2 factors → rougher
h = (hash % 5) + ((hash >> 4) % 5);
Each biome picks how many bit extractions to combine. More factors = closer to 50/50 distribution, smoother terrain. Fewer factors = stronger local variation, rougher terrain.

With only 2 factors, snowy plains produces hilly, almost mountainous terrain. Frequent peaks and valleys.

With 4 factors, plains stay flat and predictable. Distribution stabilizes.
Generating a chunk takes 200 ms on ESP32 -- versus unmeasurable (too expensive) with actual Perlin noise on the same hardware.
The killer detail : querying a block without generating the whole chunk
You're playing. You mine a block. The server needs to know what to give you. Naively, you'd generate the whole chunk.
With bilinear interpolation, you query any point directly from coordinates. Corner values come from chunk position, interpolation gives you height at any offset. A handful of integer operations, no chunk generation.
p2r3 : "what I want is a magic function that can tell me what block sits at a given coordinate, without referring to memory or calculating expensive noise maps." Exactly what he built.
Here's how height becomes actual block types :
// worldgen.c (simplified)
uint8_t getTerrainBlock (int x, uint8_t y, int z) {
uint8_t surface = getHeightAt(x, z);
if (y > surface) return B_air;
if (y == surface) return biome_top[getChunkBiome(x, z)];
if (y > surface - 4) return B_dirt;
if (y > surface - 16) return B_stone;
if (y > CAVE_BASE_DEPTH) return B_deepslate;
return B_bedrock;
}
5 conditions. A grass/dirt/stone/deepslate/bedrock cascade. The surface block depends on biome via biome_top[] -- grass for plains, sand for desert. No loops, no switches, a waterfall of ifs that falls into the right layer.
Caves : the laziest mirror
cave_y = CAVE_BASE_DEPTH - (surface_height - y);
Mirrors surface height underground. Produces deepslate-like cavities. One line.


Ores : XOR edition
candidate = (chunk_x ^ col_x ^ col_z) % 100;
if (candidate < 5 && y < 16) -> diamond
XOR of coordinates guarantees one candidate per column. Type depends on Y level. Diamonds are hidden under the lowest cave point to keep mining rewarding.
Biomes as a tile map
Each biome is a circular island in a grid, type determined by a seed-derived repeating pattern. Gridded, predictable, free.

Each biome has its own parameter set encoded in lookup tables :
// worldgen.c (simplified)
static const uint8_t biome_base[] = {
[BIOME_PLAINS] = 48, // base height : 48
[BIOME_DESERT] = 52, // slightly higher
[BIOME_FOREST] = 50, // in between
[BIOME_TAIGA] = 46, // a bit lower
[BIOME_SNOWY] = 40, // lowest
};
static const uint8_t biome_top[] = {
[BIOME_PLAINS] = B_grass,
[BIOME_DESERT] = B_sand,
[BIOME_FOREST] = B_grass,
[BIOME_TAIGA] = B_grass,
[BIOME_SNOWY] = B_snow_block,
};
static const uint8_t biome_factors[] = {
[BIOME_PLAINS] = 4, // 4 extractions → very smooth
[BIOME_DESERT] = 3, // 3 extractions → moderate
[BIOME_FOREST] = 4, // 4 extractions → smooth, rolling hills
[BIOME_TAIGA] = 3, // 3 extractions → moderate
[BIOME_SNOWY] = 2, // 2 extractions → very rough
};
Plains : height 48, 4 factors → very flat, grass.
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Result : ±4 blocks variation max
Desert : height 52, 3 factors, surface block = sand. Never below sea level.
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// Result : ±6 blocks variation max, clamped to SEA_LEVEL+1
Forest : height 50, 4 factors like plains but higher base → wooded hills.
Taiga : height 46, 3 factors → moderate variation, cold terrain.
Snowy plains : height 40, only 2 factors → the roughest terrain.
h = (hash % 5) + ((hash >> 4) % 5);
// Result : ±14 blocks variation max
Each biome is encoded in 3 arrays of 5 entries : base height, surface block, factor count. When getHeightAtFromHash receives the biome, it consults these arrays to shape the terrain. 15 bytes of data replacing Minecraft's entire biome system.
The biome detector uses the seed to determine which biome each chunk belongs to :
// worldgen.c (simplified)
static const uint8_t biome_pattern[] = {
BIOME_PLAINS, BIOME_FOREST, BIOME_PLAINS, BIOME_DESERT,
BIOME_FOREST, BIOME_TAIGA, BIOME_PLAINS, BIOME_SNOWY,
BIOME_PLAINS, BIOME_FOREST, BIOME_DESERT, BIOME_PLAINS,
BIOME_SNOWY, BIOME_PLAINS, BIOME_FOREST, BIOME_TAIGA,
};
uint8_t getChunkBiome (short cx, short cz) {
uint32_t h = splitmix64(cx * 31 + cz * 97 + world_seed);
uint8_t index = h % 16;
return biome_pattern[index];
}
A 16-entry pattern, an index seeded by chunk coordinates. It produces a gridded but visually consistent biome layout. 4 lines of code replacing vanilla Minecraft's entire biome parameter system.
getHeightAtFromHash : the terrain assembler
The function at the heart of generation combines the 4 seeded corners per biome :
// worldgen.c (simplified)
static uint8_t getHeightAtFromHash (int rx, int rz, short cx, short cz,
uint32_t h, uint8_t biome) {
// 4 corners extracted from hash, different shift per corner
uint8_t h1 = biome_base[biome] + (h & 0x0F);
uint8_t h2 = biome_base[biome] + ((h >> 4) & 0x0F);
uint8_t h3 = biome_base[biome] + ((h >> 8) & 0x0F);
uint8_t h4 = biome_base[biome] + ((h >> 12) & 0x0F);
// Biome constraint : desert never below sea level
if (biome == BIOME_DESERT) {
h1 = max(h1, SEA_LEVEL + 1);
h2 = max(h2, SEA_LEVEL + 1);
h3 = max(h3, SEA_LEVEL + 1);
h4 = max(h4, SEA_LEVEL + 1);
}
// Interpolate from the 4 corners
return interpolate(h1, h2, h3, h4, rx, rz);
}
Each biome has a biome_base shifting the reference height, and the 4 corners are extracted from the hash at different offsets. Desert clamps to above sea level -- one constraint line that avoids water without extra biome computation.
Trees and cacti : probabilistic placement
Surface generation reuses the same chunk hash to decide where to plant :
// worldgen.c (simplified)
static void genFoliage (uint8_t *chunk_data, short cx, short cz,
uint32_t hash, uint8_t biome) {
if (biome == BIOME_DESERT) {
// Cactus : one candidate per chunk, hash determines position
int tx = (hash >> 8) & 7;
int tz = (hash >> 12) & 7;
int ty = getHeightAt(cx * 8 + tx, cz * 8 + tz);
if (chunk_data[ty * 64 + tz * 8 + tx] == B_sand)
placeCactus(chunk_data, tx, ty + 1, tz);
} else {
// Trees : hash determines count and position
int tree_count = (hash & 3); // 0-3 trees per chunk
for (int i = 0; i < tree_count; i ++) {
int tx = ((hash >> (4 + i * 4)) & 7);
int tz = ((hash >> (6 + i * 4)) & 7);
int ty = getHeightAt(cx * 8 + tx, cz * 8 + tz);
placeTree(chunk_data, tx, ty + 1, tz);
}
}
}
0-3 trees per chunk for green biomes, max 1 cactus for desert. The chunk hash is the only entropy source -- & 7 for in-chunk position, & 3 for the counter. Everything deterministic, nothing stored.
generateChunk : putting it all together
The function that assembles everything into a complete 8×8×256 chunk :
// worldgen.c (simplified)
void generateChunk (uint8_t *chunk, short cx, short cz) {
uint32_t hash = getChunkHash(cx, cz);
uint8_t biome = getChunkBiome(cx, cz);
// For every column in the chunk (8×8 = 64)
for (int x = 0; x < 8; x ++) {
for (int z = 0; z < 8; z ++) {
// Absolute world coordinates
int wx = cx * 8 + x;
int wz = cz * 8 + z;
// Column height
uint8_t height = getHeightAt(wx, wz);
// Fill column bottom-up
for (int y = 0; y < height; y ++) {
uint8_t block = getTerrainBlock(wx, y, wz);
chunk[y * 64 + z * 8 + x] = block;
}
}
}
// Place surface elements (trees, cacti)
genFoliage(chunk, cx, cz, hash, biome);
}
That's it. 3 nested loops : for each column, get its height, fill in blocks, move on. Output is a uint8_t[16384] (8 × 8 × 256) representing the complete chunk. No caching, no lazy loading, no compression -- the chunk is generated and sent straight to the client.
Storage : static arrays everywhere
Bareiron's memory architecture is embedded C at its finest. No malloc, no hash maps, no linked lists. Everything is global fixed-size arrays.
Block changes
// globals.h, lines 191-196
typedef struct {
short x; // 2 bytes -- 32,000 block horizontal limit
short z; // 2 bytes
uint8_t y; // 1 byte -- 256 block vertical limit
uint8_t block; // 1 byte -- 256 block types max
} BlockChange;
20,000 entries, roughly 25,000 changes -- about 1.5 chunks fully dug out. The block field at 0xFF marks a free entry. Search is a linear scan :

// procedures.c
uint8_t getBlockChange (short x, uint8_t y, short z) {
for (int i = 0; i < block_changes_count; i ++) {
if (block_changes[i].block == 0xFF) continue;
if (block_changes[i].x == x && block_changes[i].y == y && block_changes[i].z == z)
return block_changes[i].block;
#ifdef ALLOW_CHESTS
if (block_changes[i].block == B_chest) i += 14;
#endif
}
return 0xFF;
}
Adding a change is just as direct :
static uint8_t changes_count = 0;
void addBlockChange (short x, short z, uint8_t y, uint8_t block) {
if (changes_count >= MAX_CHANGES) return;
block_changes[changes_count].x = x;
block_changes[changes_count].z = z;
block_changes[changes_count].y = y;
block_changes[changes_count].block = block;
changes_count ++;
}
A counter, an index, a write. No sorting, no compaction, no memory management. When the array fills up, new changes are ignored -- terrain reverts to its generated state.
The author on the 256 block limit : "I don't plan on implementing Waxed Lightly Weathered Cut Copper Stairs any time soon."
Mobs : 8 bytes per head
// globals.h, lines 240-251 (pragma pack(push, 1))
typedef struct {
uint8_t type; // 25=chicken, 28=cow, 95=pig, 106=sheep, 145=zombie
short x;
uint8_t y; // if health=0, Y becomes a death timer
short z;
uint8_t data; // bits 0-4: health, bit 5: sheep sheared, bits 6-7: panic timer
} MobData;
8 bytes. 16 slots. No alignment, no padding. The data byte is a hand-rolled bitfield : 5 bits health, 1 bit sheared, 2 bits panic timer. When a mob dies, the Y field becomes a deletion timer. Bit-level memory reuse.
Players : packed tight
Player data uses #pragma pack(push, 1) too -- coordinates as short + uint8_t, inventories as fixed uint16_t + uint8_t arrays, and a flags field encoding attack cooldown, spawn state, sneak, sprint, eat, load, movement cooldown, and craft lock. All in individual bits.
The main loop : while(true) and non-blocking I/O
The whole server runs on one loop, one thread, zero event library.
// main.c, lines 594-720
while (true) {
task_yield();
// Accept a new connection (non-blocking)
for (int i = 0; i < MAX_PLAYERS; i ++) {
if (clients[i] != -1) continue;
clients[i] = accept(server_fd, ...);
if (clients[i] != -1) client_count ++;
break;
}
// Server tick
if (get_program_time() - last_tick_time > TIME_BETWEEN_TICKS) {
handleServerTick(time_since_last_tick);
last_tick_time = get_program_time();
}
// Round-robin : one client, one packet per iteration
client_index = (client_index + 1) % MAX_PLAYERS;
if (clients[client_index] == -1) continue;
// Read packet header : length + ID
recv(client_fd, &recv_buffer, 2, MSG_PEEK);
int length = readVarInt(client_fd);
int packet_id = readVarInt(client_fd);
handlePacket(client_fd, length - sizeVarInt(packet_id), packet_id, state);
}
One client per iteration, one packet at a time. task_yield() at the top lets FreeRTOS idle breathe -- without it, the watchdog timer resets the chip.
Packet dispatch is a 400-line switch :
// main.c, lines 68-497
void handlePacket (int client_fd, int length, int packet_id, int state) {
switch (packet_id) {
case 0x00: // Handshake / Status / Login (depends on state)
case 0x01: // Status ping
case 0x02: // Plugin message
case 0x03: // Login/configuration acknowledgment
case 0x08: // Chat
case 0x0B: // Client status (respawn)
case 0x11: // Click container (chest GUI)
case 0x19: // Interact entity
case 0x1D..0x20: // Movement packets (biggest case)
case 0x28: // Player action (dig/place)
// ... 40+ cases
}
}
No dynamic jump table, no vtable, no map. A switch compiles to a static jump table. Perfect for embedded.
Case 0x1D-0x20 is the largest -- it handles position updates, fall damage, chunk boundary crossings, mob spawning, chunk generation, AND hunger. All in one giant fall-through.

Server tick and mob AI
handleServerTick runs every 50 ms (20 TPS). It manages the world while the main loop handles players :
// main.c (simplified)
void handleServerTick (uint32_t delta) {
// Update every mob
for (int i = 0; i < MOB_COUNT; i ++) {
if (mobs[i].type == 0 || mobs[i].data == 0) continue; // dead or empty
MobData *mob = &mobs[i];
int px, pz;
getNearestPlayer(mob->x, mob->z, &px, &pz);
if (mob->type == MOB_ZOMBIE) {
// Hostile : walk toward nearest player
if (px < mob->x) mob->x --;
else if (px > mob->x) mob->x ++;
if (pz < mob->z) mob->z --;
else if (pz > mob->z) mob->z ++;
// Contact damage at 2 blocks
if (abs(px - mob->x) <= 2 && abs(pz - mob->z) <= 2)
damagePlayer(getNearestPlayerId(mob->x, mob->z), 3);
} else {
// Passive : 8 random directions
uint8_t dir = getMobDir(mob);
mob->x += dir_lookup[dir][0];
mob->z += dir_lookup[dir][1];
// Change direction every ~40 ticks
if (mob->data >> 6 < 1) setMobDir(mob, rand() & 7);
mob->data = (mob->data & 0x3F) | ((mob->data - 0x40) & 0xC0);
}
// Wake chunks around the mob
setChunkGenerated(mob->x / 8, mob->z / 8);
}
}
Hostile mob AI is a coordinate comparison. Literally if (px < x) x--. No pathfinding, no A*, no obstacle avoidance. The zombie adjusts X and Z independently toward the player -- it walks through walls if there are any.
Contact damage is set at 3 hearts/sec. p2r3 deliberately made it high because the lack of pathfinding makes zombies easy to kite.
The armor formula is pre-combat-update -- the simplest possible :
// main.c (simplified)
static uint8_t applyArmor (uint8_t damage, uint16_t armor_value) {
// Pre-1.9 formula : linear reduction
// Each armor point = 4% reduction, max 80%
uint8_t reduction = (armor_value * 4);
if (reduction > 80) reduction = 80;
return damage * (100 - reduction) / 100;
}
Full diamond = 80% reduction. A 3-heart zombie hit becomes 0.6 hearts. p2r3 chose this old formula because it computes in 2 operations -- no thresholds, no curves, just a linear percentage.
Passive mobs : 8 directions from a lookup table, course change every ~40 ticks. The data field encodes the current direction in the top 2 bits and the direction change timer in the remaining 6 bits.

Mob respawning
Mobs don't spawn from random ticks. They appear when the server tick detects a new chunk boundary :
if (player crossed chunk boundary) {
for (int i = 0; i < MOB_COUNT; i ++) {
if (mobs[i].type != 0) continue;
spawnMob(&mobs[i], new_chunk_coords, getChunkHash(cx, cz));
break;
}
}
Same RNG as the terrain, same chunk seed. If a mob slot is free, the spawn is deterministic.
Crafting : no matrices, just if/else
// crafting.c, lines 9-347 (simplified)
void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
if (player->flags & 0x80) { *count = 0; *item = 0; return; }
uint8_t filled = 0, first = 10, identical = true;
for (int i = 0; i < 9; i ++) {
if (player->craft_items[i]) {
filled ++;
if (first == 10) first = i;
else if (player->craft_items[i] != player->craft_items[first])
identical = false;
}
}
switch (filled) {
case 1: /* planks, ingots... */
case 2: /* sticks, shears, torches */
case 3: /* shovels, swords, slabs */
case 4: /* crafting table, boots */
case 5: /* pickaxes, axes, helmets */
case 7: /* leggings, composters */
case 8: /* furnace, chest, chestplates */
case 9: /* full blocks (iron, gold, etc.) */
}
}
First check : if flag 0x80 is set, the craft buffer is recycled as a chest pointer. No crafting allowed.
Then it counts filled slots, notes the first item, checks identity. With just this, a furnace matches in 4 checks :
if (count == 8 && first == cobblestone && all_identical && center_empty)
return furnace;
Complex shapes use the first item's index to check relative positions. Recipes share one matching function -- material determines output.

Chests : the real memory hack
The hack everyone talks about, in actual code :
// procedures.c, lines 1262-1293
if (target == B_chest) {
uint8_t *storage_ptr = NULL;
for (int i = 0; i < block_changes_count; i ++) {
if (block_changes[i].block != B_chest) continue;
if (block_changes[i].x != x || block_changes[i].y != y || block_changes[i].z != z)
continue;
storage_ptr = (uint8_t *)(&block_changes[i + 1]);
break;
}
if (storage_ptr == NULL) return;
// Terrible memory hack!!
memcpy(player->craft_items, &storage_ptr, sizeof(storage_ptr));
player->flags |= 0x80;
sc_openScreen(player->client_fd, 2, "Chest", 5);
for (int i = 0; i < 27; i ++) {
uint16_t item;
uint8_t count;
memcpy(&item, storage_ptr + i * 3, 2);
memcpy(&count, storage_ptr + i * 3 + 2, 1);
sc_setContainerSlot(player->client_fd, 2, i, count, item);
}
}
And the comment in the source : // Terrible memory hack!!1!
It takes the memory address of the next entry in block_changes[], copies it into player->craft_items (a uint16_t[9], 18 bytes -- enough for a 32-bit pointer), and sets the flag so nobody tries to craft.
On every click in the chest GUI :
// packets.c, lines 620-638
uint8_t *storage_ptr;
memcpy(&storage_ptr, player->craft_items, sizeof(storage_ptr));
uint16_t *p_item = (uint16_t *)(storage_ptr + (slot - 41) * 3);
uint8_t *p_count = storage_ptr + (slot - 41) * 3 + 2;
It recovers the pointer from the craft buffer and accesses slots with an offset. Chest data is stored at 3 bytes per slot (2 for item ID, 1 for count), packed sequentially in the block array.

Hunger : 5 lines of genius
// main.c, lines 293-305
if (player->saturation == 0) {
if (player->hunger > 0) player->hunger--;
player->saturation = 200;
sc_setHealth(client_fd, player->health, player->hunger, player->saturation);
} else if (player->flags & 0x08) { // sprinting
player->saturation -= 1;
}
That's it. 5 lines. Every movement packet decrements saturation. When saturation hits zero, hunger drops and saturation resets. Sprinting (flag 0x08) doubles the drain.
Zero timers. Zero allocated memory. Zero dedicated compute. One counter piggybacking on packets that already exist.
Fall damage
damage = last_y_on_ground - current_y;
A subtraction.
Mining and placing blocks
When you click on a block, the 0x28 (Player Action) packet lands in the switch. The handler figures out what block is there, removes it, and puts the item in your inventory :
// main.c, case 0x28 (simplified)
void handlePlayerAction (int client_fd, uint8_t action, int x, uint8_t y, int z) {
switch (action) {
case START_DESTROY_BLOCK: {
uint8_t block = getBlockAt(x, y, z);
if (block == B_chest) {
openChest(client_fd, x, y, z);
break;
}
// Add to block_changes
addBlockChange(x, z, y, 0); // 0 = air
// Give item to player (trust the client)
addItemToPlayer(client_fd, block_to_item(block), 1);
// Send update to client
sc_blockChange(client_fd, x, y, z, 0);
sc_ackBlockChange(client_fd, x, y, z, 0);
break;
}
case PLACE_BLOCK: {
uint16_t item = getHeldItem(client_fd);
uint8_t block = item_to_block(item);
addBlockChange(x, z, y, block);
removeItemFromPlayer(client_fd, item, 1);
sc_blockChange(client_fd, x, y, z, block);
break;
}
}
}
getBlockAt combines terrain generation AND player changes :
uint8_t getBlockAt (int x, uint8_t y, int z) {
// Check player changes first
uint8_t change = getBlockChange(x, y, z);
if (change != 0xFF) return change;
// Otherwise read from generated terrain
return getTerrainBlock(x, y, z);
}
Changes first, terrain as fallback. Zero debate, zero cache, zero overhead. Under the hood, getTerrainBlock is getHeightAt plus stone/dirt/grass/coal layers.
The instant furnace
The best part : the furnace doesn't exist as an entity. Put cobblestone in the "smelt" slot and coal in "fuel", the result appears immediately. No timer, no chunk ticking. It's just an inventory slot that empties when you put the right items in.

The ESP32 boot : a Minecraft server in 4 KB of stack
// main.c, lines 732-779
#ifdef ESP_PLATFORM
void bareiron_main (void *pvParameters) {
main();
vTaskDelete(NULL);
}
static void wifi_event_handler (...) {
if (/* connected */) {
xTaskCreate(bareiron_main, "bareiron", 4096, NULL, 5, NULL);
}
}
void app_main () {
esp_timer_early_init();
wifi_init();
}
#endif
The whole server runs in a FreeRTOS task with 4096 bytes of stack. The main thread only initializes WiFi and waits for a connection. Once connected, it spawns bareiron_main which calls the standard main().
ESP32-specific code is behind #ifdef ESP_PLATFORM. On desktop, it compiles as standard POSIX.
What got sacrificed
- No network compression -- zlib too expensive. Generating chunks is fast, sending them is the bottleneck.
- No random ticks -- trees grow with bone meal or not at all. Mobs spawn at chunk boundaries.
- No item entities -- mined blocks go straight to inventory. Visual animation only.
- No inventory validation -- server trusts the client. 64 diamonds ? OK. Don't use with strangers.
- No server-side lighting -- torches sent after other blocks, client calculates.
- No gradual fluid flow -- water and lava reach final state instantly.
The result
Ryzen 5 3600 : ~0.5 ms per chunk. ESP32-C3 at $1 : ~200 ms per chunk. Playable.

3+ players : lag. Author compares it to 2b2t peak hours.

The philosophy
p2r3 : "I just really like the idea that this tiny little chip that costs one friggin' dollar and consumes half a Watt can run something as advanced as Minecraft. Science isn't about 'why', it's about 'why not'."
Every line is a conscious tradeoff :
- Perlin noise → interpolation : uglier, 200x faster, zero memory
- Crafting matrices → hardcoded matching : disgusting code, zero bytes
- zlib → nothing : bad connections die, but it runs
- Validation → trust : no security, zero compute
Every missing feature pays for another to exist within the hardware limits.
3 things to remember :
- Interpolation + RNG -- 4 seeded points, infinite terrain, zero storage, query without regenerating the chunk, 200 ms generation. The single move that makes everything else possible.
- Every feature costs something -- No compression, no random ticks, no validation. Not oversights, they're what keeps things in 520 KB.
- The nastiest hacks are the smartest -- Chests in the block array via memcpy, hunger through movement packets, instant furnace. The "clean" solution would have been too expensive.
The repo is GPLv3. It's beautiful dirty C xD