Introduction
Running a Minecraft server on a one-dollar microcontroller sounds like a joke.
I asked the question, and the answer is yes. Literally.
There's a project called Bareiron, by p2r3, and it's probably one of the most fascinating projects I've seen in the Minecraft world in recent years. We're talking a binary fitting in 300 kilobytes, 6800 lines of C, zero external dependencies, no malloc, no threading, and it runs on a one-dollar ESP32.

Infinite terrain generation. Biomes. Caves. Crafting. Mining. Mobs. Hunger. Chests. Everything you expect from a survival server.
On a chip consuming 0.5W with a 160MHz clock.
To give an order of magnitude: a vanilla Minecraft server needs several gigs of RAM. The ESP32-C3 is 520KB of SRAM (400K usable after boot). CPUs 20 years ago already ran in gigahertz -- this one tops out at 160MHz. The raw power factor between the two is about 20,000.
p2r3 didn't write a Minecraft server in C, he reinvented every server brick to fit those constraints. Let's look at how, by opening the source code.

The project's brain: memoryless terrain generation
The biggest problem when you want an embedded MC server is terrain generation.
In vanilla Minecraft, the world generates with Perlin noise: several stacked layers (octaves), 6 biome parameters (temperature, humidity, continentalness, erosion, weirdness, depth), and a whole caching system to avoid recomputing everything each time.
The result is gorgeous. But it's expensive in compute, and it takes RAM to store generated chunks.
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 small pixelated image and the edges get blurry? That's exactly it.
// 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); // chunk coordinates
int _z = floor(z / CHUNK_SIZE);
int rx = x % CHUNK_SIZE; // offset inside chunk
int rz = z % CHUNK_SIZE;
uint32_t hash = getChunkHash(_x, _z);
uint8_t biome = getChunkBiome(_x, _z);
// interpolate between 4 corners seeded by hash + biome
return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}
Standard bilinear interpolation: 4 corners, position-based weights, a single uint8_t out. CHUNK_SIZE is 8, so it's all integer multiplication, no floats.
p2r3 shows it step by step in the video: first the chunk's 4 corners, each with an RNG-seeded height.

Then interpolating between those 4 points creates a continuous surface.

And repeating the pattern over all adjacent chunks gives terrain extending to infinity.

The deterministic RNG
The key making all of this possible is seeding. Each chunk has 4 corners, and each corner needs a unique but 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)); // hash
}
It packs 16 bits of X, 16 bits of Z, and 32 bits of seed into an 8-byte buffer, and runs it all through splitmix64. Result: a unique deterministic value per position, based on the world seed.
You catch the power of the mechanism? The server doesn't need to store terrain. It recomputes on the fly when the player arrives in a new zone, and it gives exactly the same result every time.
The splitmix64 used is an ultra-fast PRNG designed for 64-bit hashes:
// 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 ops: addition, xor/shift, multiplication, xor/shift, multiplication, xor/shift. All in direct ops, no table or loop. It takes the 8-byte buffer (X + Z + seed), treats it as a 64-bit int, and returns 32 bits of hash. Deterministic, fast, fits in 5 lines.
Why it's not Perlin noise
p2r3 says it himself in the video: "the more digits of the random number you add, the more regular the terrain gets, like more coin flips getting you closer to 50/50". In practice, it's the number of hash bits he combines:
// worldgen.c, lines 51-115
// For a 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 it combines. The more there are, the more the distribution stabilizes -- like more coin flips approaching 50/50. The fewer, the stronger local variations.

With only 2 factors, snowy plains produce a hilly, almost mountainous terrain. Peaks and dips are frequent.

With 4 factors, plains stay flat and predictable. The distribution stabilizes.
One chunk generates in 200ms on ESP32 -- versus unmeasurable time on the same hardware with Perlin noise, it's that expensive.
The killer detail: querying a block without generating the whole chunk
You play, you mine a block. The server must know which item to give you. Naively, it would need to generate the whole chunk for that.
With bilinear interpolation, you query any point of the plane directly from coords. The chunk corners come from the player position, interpolation gives the height at any offset. A handful of math ops, no chunk generation.
p2r3: "what I want is a magic function that can tell me which block sits at a given coordinate, without accessing memory or computing expensive noise maps". Exactly what he built.
Here's how height becomes concrete blocks:
// 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. One grass/dirt/stone/deepslate/bedrock layer. The surface block depends on the biome via biome_top[] -- grass for plains, sand for desert. A simple cascade of ifs falls into the right layer, replacing loop and switch.
Caves, the laziest mirror
cave_altitude = CAVE_BASE_DEPTH - (surface_height - y);
It mirrors surface height underground. That looks like big deepslate cavities. Zero compute, one line.


Ores, XOR version
candidate = (chunk_x ^ col_x ^ col_z) % 100;
if (candidate < 5 && y < 16) -> diamond
One XOR of coords guarantees one candidate per column. Type depends just on altitude. Diamonds hide under the caves' lowest point to keep digging useful.
Tile-map biomes
Each biome is a circular island in a grid, its type set by a seed-computed pattern. Gridded, predictable, and free.

Each biome has its own parameter set encoded in arrays:
// 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, // the 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 regular
[BIOME_DESERT] = 3, // 3 extractions → moderate
[BIOME_FOREST] = 4, // 4 extractions → regular, hilly
[BIOME_TAIGA] = 3, // 3 extractions → moderate
[BIOME_SNOWY] = 2, // 2 extractions → very rough
};
Plains: height 48, 4 factors → very flat terrain, grass.
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Result: ±4 blocks max variation
Desert: height 52, 3 factors, surface block = sand. Never below sea level.
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// Result: ±6 blocks max variation, clamped to SEA_LEVEL+1
Forest: height 50, 4 factors like plains but higher base → wooded hills.
Taiga: height 46, 3 factors → moderate variations, cold terrain.
Snowy plains: height 40, only 2 factors → the roughest.
h = (hash % 5) + ((hash >> 4) % 5);
// Result: ±14 blocks max variation
Each biome encodes in 3 arrays of 5 entries: base height, surface block, factor count. When getHeightAtFromHash gets the biome, it consults those tables to tune terrain. 15 bytes of data replacing Minecraft's whole biome system.
The biome detector uses the seed to determine which biome matches each chunk:
// 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 coords. That gives a repetitive but visually coherent grid. 4 lines of code replacing all of vanilla Minecraft's biome parameter system.
getHeightAtFromHash: the terrain assembler
The function at the heart of generation combining the 4 biome-seeded corners:
// 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 seed 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 underwater
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 extract from the hash with different shifts. The desert forces the min above sea level -- one constraint line avoiding water with no extra biome compute.
Trees and cacti: probabilistic placement
Surface generation uses 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 whether to place and where
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, 1 cactus max for desert. The chunk hash is the only entropy source -- an & 7 for position in the chunk, an & 3 for the counter. All deterministic, nothing stored.
generateChunk: putting it all together
The function assembling everything to produce a full 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 each column of 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 the column bottom to top
for (int y = 0; y < height; y ++) {
uint8_t block = getTerrainBlock(wx, y, wz);
chunk[y * 64 + z * 8 + x] = block;
}
}
}
// Add surface elements (trees, cacti)
genFoliage(chunk, cx, cz, hash, biome);
}
That's it. 3 nested loops: for each column, find the height, fill the blocks, move to the next. Output is a uint8_t[16384] (8 × 8 × 256) representing the full chunk. The chunk generates then ships straight to the client, with no caching, lazy loading, or compression.
Storage: static arrays everywhere
Bareiron's memory architecture is embedded C in all its glory: only fixed-size global arrays, no malloc, no hash maps, no linked lists.
Block changes
// globals.h, lines 191-196
typedef struct {
short x; // 2 bytes -- capped at 32,000 blocks horizontal
short z; // 2 bytes
uint8_t y; // 1 byte -- capped at 256 blocks vertical
uint8_t block; // 1 byte -- capped at 256 block types
} BlockChange;
20,000 entries, about 25,000 changes -- the equivalent of a chunk and a half fully dug out. The block field at 0xFF marks a free entry. Lookup 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; // skip chest data
#endif
}
return 0xFF;
}
Adding a change is as direct as lookup:
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 ++;
}
One counter, one index, one write. The array just piles up: no sorting, no compaction, no memory management. When the array is full, new changes are ignored -- terrain reverts to its generated state.
The author's comment on the 256-block cap: "I don't plan to implement lightly weathered waxed copper stairs anytime soon."
Mobs: 8 bytes per pipe head
// globals.h, lines 240-251 (pragma pack(push, 1) to kill padding)
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 deletion timer
short z;
uint8_t data; // bits 0-4: health, bit 5: sheep sheared, bits 6-7: panic timer
} MobData;
8 bytes. 16 slots max. Alignment and padding thrown out. The data byte is a homemade bitfield: 5 bits of health, 1 shear bit, 2 panic-timer bits. And when a mob dies, the Y field becomes a deletion timer. Memory reuse down to the bit.
Players: tightly packed
Player data uses #pragma pack(push, 1) too -- coords in short + uint8_t, inventories in 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
The whole server runs on one loop, one thread, zero event library.
// main.c, lines 594-720
while (true) {
task_yield(); // let the FreeRTOS idle task breathe on ESP32
// 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 time elapsed
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 handled per loop iteration, one packet read at a time. The task_yield() at the loop start lets the FreeRTOS idle task breathe on ESP32 -- without it, the watchdog timer resets the chip.
Packet dispatch is a monstrous 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 depending 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 (handles chests)
case 0x19: // Interact entity
case 0x1D..0x20: // Movement packets (biggest case)
case 0x28: // Player action (dig/place)
// ... 40+ cases
}
}
A switch compiles to a static jump table, replacing dynamic jump table, vtable, and map. Perfect for embedded.
The 0x1D-0x20 case is the biggest -- it handles position updates, fall damage, chunk-border crossings, mob spawning, chunk generation, AND hunger. All in one big fall-through.

The server tick and mob AI
The handleServerTick function runs every 50ms (20 TPS). It manages the world while the main loop handles players:
// main.c (simplified)
void handleServerTick (uint32_t delta) {
// Update each 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];
// Direction change 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--. The zombie ignores pathfinding, A*, and obstacle avoidance: it adjusts X and Z independently toward the player -- it walks through walls if there are any.
Contact damage is 3 hearts/sec. p2r3 wanted it high because the lack of pathfinding makes zombies easy to kite.
The armor formula is the pre-combat-update one -- 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 zombie hit at 3 hearts becomes 0.6 hearts. p2r3 picked that old formula because it computes in 2 ops -- no thresholds, no curves, just a linear percentage.
Passive mobs: 8 directions in a lookup table, heading change every ~40 ticks. The data field encodes the current direction in the 2 high bits, and the direction-change timer in the 6 remaining bits.

Mob respawning
Mobs don't spawn on random ticks. They appear when the server tick meets a new chunk border:
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 terrain, same chunk seed. If a mob slot is free, spawning is deterministic.
Crafting: no matrices, if/else
// crafting.c, lines 9-347 (simplified)
void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
// If flag 0x80 is set, the craft buffer is used by a chest
if (player->flags & 0x80) { *count = 0; *item = 0; return; }
// Count filled slots, find first item, check identity
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, chestplate */
case 9: /* full blocks (iron, gold, etc.) */
}
}
First check: if the 0x80 flag is set, the craft buffer is recycled as a chest pointer. Craft stays locked.
Then it counts filled slots, notes the first item, checks identity. With just that, you match the furnace in 4 checks:
if (count == 8 && first == cobblestone && all_identical && center_empty)
return furnace;
For complex shapes, it uses the first item's index and checks relative position. Recipes share one matching function -- material determines the result.

Chests: the real hack
The memory hack everyone talks about, in real code:
// procedures.c, lines 1262-1293
if (target == B_chest) {
// Find the chest entry in the block array
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]); // point after the chest block
break;
}
if (storage_ptr == NULL) return;
// Terrible memory hack!!
// We copy the POINTER into the player's craft item array
memcpy(player->craft_items, &storage_ptr, sizeof(storage_ptr));
player->flags |= 0x80; // lock crafting
// Send the chest UI to the client
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 code: // Terrible memory hack!!1!
Exactly that. It takes the memory address of the next entry in block_changes[], copies it into player->craft_items (a uint16_t[9], so 18 bytes -- enough to hold a 32-bit pointer), and raises the flag so nobody tries crafting meanwhile.
On each click in the chest inventory:
// packets.c, lines 620-638
uint8_t *storage_ptr;
memcpy(&storage_ptr, player->craft_items, sizeof(storage_ptr));
// storage_ptr now points to the chest data
uint16_t *p_item = (uint16_t *)(storage_ptr + (slot - 41) * 3);
uint8_t *p_count = storage_ptr + (slot - 41) * 3 + 2;
It pulls the pointer back from the craft buffer, and accesses slots with an offset. Chest data sits at 3 bytes per slot (2 for ID, 1 for count), stuck together in the block array.

Hunger: 5 lines of genius
// main.c, lines 293-305
// Players send movement packets at ~20/sec when
// moving, far fewer when still. We correlate that
// with activity to simulate hunger for free.
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;
}
}
Literally that. 5 lines. Each movement packet decrements saturation. When saturation hits zero, hunger drops and saturation resets. Sprint (flag 0x08) doubles the drain.
Zero timers, zero allocated memory, zero dedicated compute. A counter decrementing on packets that already exist.
Fall damage
The project's simplest damage system:
damage = last_ground_y - current_y
One subtraction.
Mining and placing blocks
When you click a block, packet 0x28 (Player Action) lands in the switch. The handler must figure out which block sits at the position, remove it, and put the item in 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: {
// Determine the block type at the clicked position
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 the item to the player (trust the client)
addItemToPlayer(client_fd, block_to_item(block), 1);
// Send the update to the client
sc_blockChange(client_fd, x, y, z, 0);
sc_ackBlockChange(client_fd, x, y, z, 0);
break;
}
case PLACE_BLOCK: {
// Read the block type from the player's hand
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 generated terrain 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, generated fallback. Zero debate, zero cache, zero overhead. Under the hood getTerrainBlock is getHeightAt + the stone/dirt/grass/coal layers.
The instant furnace
The funniest: the furnace doesn't exist as an entity. If you put cobblestone in the "cooking" slot and coal in "fuel", the result appears immediately, no timer or chunk ticking: it's just an inventory slot emptying when you put the right items in.

The ESP32 loop: an MC server in 4KB 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();
// The rest is handled by the event handler
}
#endif
The whole server runs in a FreeRTOS task with 4096 bytes of stack. That's it. The main thread just initializes WiFi and waits for a connection. Once connected, it spawns bareiron_main calling standard main().
All ESP32-specific code is guarded by #ifdef ESP_PLATFORM. On PC, all of it compiles to standard POSIX code.
What got sacrificed
For all of that to fit, vanilla features that don't exist:
- Zero network compression -- zlib too expensive. The server generates chunks fast, but sending them is the bottleneck.
- Zero random ticks -- trees grow with bone meal or not. Mobs spawn at chunk borders.
- Zero item entities -- mined blocks go straight to inventory. The animation is purely visual.
- No inventory validation -- trust the client. 64 diamonds? OK. A chunk mined in 1 sec? OK. For use among trusted people.
- Zero server-side light -- torches sent after everything else, the client computes.
- Zero progressive fluids -- instant final state.
The final result
Ryzen 5 3600: ~0.5ms per chunk. $1 ESP32-C3: ~200ms per chunk. Playable.

3+ players: it lags. Comparable to 2b2t at peak hours, per the author.

The philosophy
p2r3: "I just love the idea that this tiny $1 chip consuming 0.5W can run something as advanced as Minecraft. Science isn't about 'why', it's about 'why not'."
Every line is a tradeoff:
- Perlin noise → interpolation: less pretty, 200x faster, zero memory
- Craft matrices → hardcoded matching: ugly code, zero bytes
- zlib → nothing: bad connection = death, but playable
- Validation → trust: zero security, zero compute
Every missing feature lets another exist within hardware limits.
The 3 points to remember:
- Interpolation + RNG -- 4 seeded points, infinite terrain, zero storage, query without regenerating the chunk, 200ms generation. It's the genius move making everything else possible.
- Every feature has a cost -- No compression, random ticks, or validation is a deliberate choice: it's what fits in 520KB.
- Nasty hacks are the smartest -- Chests in the block array via memcpy, hunger via movement packets, instant furnace. The clean solution would have been too expensive.
If the project interests you, it's all on GitHub under GPLv3. It's nasty C, and I rarely had that much fun reading source code.