GitHub avatar

Fox's Blog

Bareiron -- a Minecraft server running on a $1 microcontroller

6800 lines of C, zero malloc, Perlin noise replaced by bilinear

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.

The ESP32-C3 microcontroller powering the server

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.

Video thumbnail for Bareiron by p2r3 on YouTube

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.

The 4 chunk corners, each seeded by the deterministic RNG

Then interpolation between those 4 points creates a continuous surface.

Applying bilinear interpolation between the 4 corners

And repeating the pattern across adjacent chunks produces infinite terrain.

Final result : continuous irregular 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.

Rough terrain -- few factors, strong variation

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

Smooth terrain -- multiple factors, flat surface

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.

Caves generated by mirroring surface terrain

Surface mirroring diagram for cave generation

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.

Biome tile map -- each island is a different biome

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 :

Block change array memory layout -- 6 bytes per entry

// 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.

Bareiron server source code -- 6800 lines of C

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.

Mobs in Bareiron -- zombies, pigs, sheep

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.

Crafting and chest interface in Bareiron

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.

Chest data stored inside the block change array -- a memory hack

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.

Instant furnace -- put ingredients in, result out immediately

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.

Chunk generation benchmark -- Ryzen vs ESP32

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

Multiple players connected to the same Bareiron server

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 :

  1. 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.
  2. Every feature costs something -- No compression, no random ticks, no validation. Not oversights, they're what keeps things in 520 KB.
  3. 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

Bareiron -- le serveur Minecraft qui tourne sur un microcontrôleur à 1$

6800 lignes de C, zéro malloc, du Perlin noise remplacé par de la

Introduction

Tu t'es déjà demandé si on pouvait faire tourner un serveur Minecraft sur un microcontrôleur à 1 balles ?

Moi oui. Et la réponse c'est oui. Littéralement.

Y'a un projet qui s'appelle Bareiron, signé p2r3, et c'est probablement l'un des projets les plus fascinants que j'aie vus dans le monde Minecraft ces dernières années. On parle d'un binaire qui tient dans 300 kilooctets, 6800 lignes de C, zéro dépendance externe, pas de malloc, pas de threading, et ça tourne sur une ESP32 à 1 dollar.

ESP32-C3, le microcontrôleur qui fait tourner le serveur

Génération de terrain infinie. Des biomes. Des grottes. Du craft. De la mine. Des mobs. De la faim. Des coffres. Tout ce que t'attends d'un serveur survival.

Sur une puce qui consomme 0.5 Watt et qui a 160 MHz de clock.

Pour donner un ordre d'idée : un serveur Minecraft vanilla a besoin de plusieurs gigas de RAM. L'ESP32-C3, c'est 520 KB de SRAM (400 dispo après le boot). Les processeurs y'a 20 ans tournaient déjà en gigahertz -- celui-ci plafonne à 160 MHz. Le facteur entre les deux en puissance pure, c'est environ 20 000.

p2r3 a pas écrit un serveur Minecraft en C, il a réinventé chaque brique du serveur pour que ça tienne dans ces contraintes. On va regarder comment, en ouvrant le code source.

Miniature de la vidéo de présentation de Bareiron par p2r3

Le cerveau du projet : une génération de terrain sans mémoire

Le plus gros problème quand tu veux faire un serveur MC embarqué, c'est la génération de terrain.

Dans Minecraft vanilla, le monde est généré avec du Perlin noise : plusieurs couches superposées (des octaves), 6 paramètres biomiques (température, humidité, continentalité, érosion, weirdness, profondeur), et tout un système de caching pour pas avoir à tout recalculer à chaque fois.

Le résultat est magnifique. Mais c'est cher en calcul, et ça prend de la RAM pour stocker les chunks générés.

L'approche de Bareiron est radicalement différente. Au lieu d'empiler du bruit, il utilise de la bilinear interpolation sur 4 points générés par un RNG déterministe.

Tu sais quand tu agrandis une petite image pixelisée et que les bords deviennent flous ? C'est exactement ça.

// worldgen.c, lignes 117-171 (simplifié)

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);
  // interpolation between 4 corners seeded by hash + biome
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

L'interpolation bilinéaire standard : 4 coins, des poids selon la position, un seul uint8_t en sortie. CHUNK_SIZE est 8, donc ça se fait en multiplications entières, pas de float.

p2r3 le montre étape par étape dans la vidéo : d'abord les 4 coins du chunk, chacun avec une hauteur seedée par le RNG.

Les 4 coins du chunk, chacun seedé par le RNG déterministe

Puis l'interpolation entre ces 4 points crée une surface continue.

Application de la bilinear interpolation entre les 4 coins

Et en répétant le pattern sur tous les chunks adjacents, on obtient un terrain qui s'étend à l'infini.

Résultat final : terrain irrégulier continu

Le RNG déterministe

La clé qui rend tout ça possible, c'est le seeding. Chaque chunk a 4 coins, et chaque coin a besoin d'une valeur pseudo-aléatoire unique mais reproductible.

// worldgen.c, lignes 13-22

uint32_t getChunkHash (short x, short z) {
  uint8_t buf[8];
  memcpy(buf, &x, 2);          // 16 bits de coordonnée X
  memcpy(buf + 2, &z, 2);      // 16 bits de coordonnée Z
  memcpy(buf + 4, &world_seed, 4);  // 32 bits de seed globale
  return splitmix64(*((uint64_t *)buf));  // hash
}

Il packe les 16 bits de X, 16 bits de Z, et 32 bits de seed, dans un buffer de 8 bytes, et il passe le tout dans splitmix64. Résultat : une valeur déterministe unique pour chaque position, basée sur la seed du monde.

Tu captes la puissance du truc ? Le serveur n'a pas besoin de stocker le terrain. Il recalcule à la volée quand le joueur arrive dans une nouvelle zone, et ça donne exactement le même résultat à chaque fois.

Le splitmix64 utilisé est un prng ultra-rapide conçu pour les hash 64 bits :

// worldgen.c (simplifié)

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 opérations : addition, xor/shift, multiplication, xor/shift, multiplication, xor/shift. Pas de lookup table, pas de boucle. Il prend le buffer de 8 bytes (X + Z + seed), le traite comme un entier 64 bits, et renvoie 32 bits de hash. C'est déterministe, rapide, et tient en 5 lignes.

Pourquoi c'est pas du Perlin noise

p2r3 le dit lui-même dans la vidéo : "plus tu ajoutes de digits du nombre aléatoire, plus le terrain devient régulier, comme plus de lancers de pièces te rapprochent de 50/50". En pratique, c'est le nombre de bits du hash qu'il combine :

// worldgen.c, lignes 51-115

// Pour un biome plains : 4 facteurs combinés → terrain régulier
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Pour snowy plains : 2 facteurs → plus accidenté
h = (hash % 5) + ((hash >> 4) % 5);

Chaque biome choisit combien de extractions de bits il combine. Plus y en a, plus la distribution se stabilise -- comme plus de lancers de pièces qui approchent 50/50. Moins y en a, plus les variations locales sont fortes.

Terrain irrégulier -- peu de facteurs, variations fortes

Avec seulement 2 facteurs, le snowy plains produit un terrain vallonné, presque montagneux. Les pics et les creux sont fréquents.

Terrain régulier -- facteurs multiples, surface lisse

Avec 4 facteurs, les plaines restent plates et prévisibles. La distribution se stabilise.

Un chunk se génère en 200 ms sur ESP32 -- contre un temps non mesurable sur le même hardware avec Perlin noise tellement c'est cher.

Le détail qui tue : interroger un bloc sans générer tout le chunk

Tu joues, tu mines un bloc. Le serveur doit savoir quel item te donner. Naïvement, il faudrait générer tout le chunk pour ça.

Avec la bilinear interpolation, tu interroges n'importe quel point du plan directement depuis les coordonnées. Les coins du chunk s'obtiennent depuis la position du joueur, l'interpolation te donne la hauteur à n'importe quel offset. Une poignée d'opérations mathématiques, pas de génération de chunk.

p2r3 : "ce que je veux, c'est une fonction magique qui peut me dire quel bloc se trouve à une coordonnée donnée, sans accéder à la mémoire ni calculer des cartes de bruit chères". Exactement ce qu'il a fait.

Voici comment la hauteur devient des blocs concrets :

// worldgen.c (simplifié)

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. Une couche de grass/dirt/stone/deepslate/bedrock. Le bloc de surface dépend du biome via biome_top[] -- grass pour les plaines, sand pour le désert. Pas de boucle, pas de switch, une cascade de if qui tombe dans la bonne couche.

Les grottes, mirror le plus paresseux

altitude_grotte = CAVE_BASE_DEPTH - (hauteur_surface - y);

Il mirror la hauteur de la surface sous terre. Ça ressemble aux grandes cavités deepslate. Zéro compute, une ligne.

Caves générées par mirror du terrain de surface

Schéma du mirror de terrain pour générer les grottes

Les minerais, version XOR

candidat = (chunk_x ^ col_x ^ col_z) % 100;
if (candidat < 5 && y < 16) -> diamond

Un XOR de coordonnées garantit un candidat par colonne. Le type dépend juste de l'altitude. Les diamants sont planqués sous le point le plus bas des grottes pour que creuser reste utile.

Les biomes en tile map

Chaque biome est une île circulaire dans une grille, son type déterminé par un pattern calculé depuis la seed. Gridé, prévisible, et gratuit.

Carte des biomes en tile map -- chaque île est un biome différent

Chaque biome a son propre jeu de paramètres encodé dans des tableaux :

// worldgen.c (simplifié)

static const uint8_t biome_base[] = {
  [BIOME_PLAINS]  = 48,   // hauteur de base : 48
  [BIOME_DESERT]  = 52,   // légèrement plus haut
  [BIOME_FOREST]  = 50,   // entre les deux
  [BIOME_TAIGA]   = 46,   // un peu plus bas
  [BIOME_SNOWY]   = 40,   // le plus bas
};

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 → très régulier
  [BIOME_DESERT]  = 3,   // 3 extractions → modéré
  [BIOME_FOREST]  = 4,   // 4 extractions → régulier, vallonné
  [BIOME_TAIGA]   = 3,   // 3 extractions → modéré
  [BIOME_SNOWY]   = 2,   // 2 extractions → très accidenté
};

Plains : hauteur 48, 4 facteurs → terrain très plat, herbe.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Résultat : variation de ±4 blocs max

Desert : hauteur 52, 3 facteurs, bloc surface = sable. Jamais sous le niveau de la mer.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// Résultat : variation de ±6 blocs max, clampé à SEA_LEVEL+1

Forest : hauteur 50, 4 facteurs comme plains mais base plus haute → collines boisées.

Taiga : hauteur 46, 3 facteurs → variations modérées, terrain froid.

Snowy plains : hauteur 40, seulement 2 facteurs → le plus accidenté.

h = (hash % 5) + ((hash >> 4) % 5);
// Résultat : variation de ±14 blocs max

Chaque biome est encodé en 3 tableaux de 5 entrées : hauteur de base, bloc de surface, nombre de facteurs. Quand getHeightAtFromHash reçoit le biome, elle consulte ces tableaux pour ajuster le terrain. 15 bytes de données pour remplacer tout le système de biomes de Minecraft.

Le détecteur de biome utilise la seed pour déterminer quel biome correspond à chaque chunk :

// worldgen.c (simplifié)

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];
}

Un pattern de 16 entrées, un index seedé par les coordonnées du chunk. Ça donne une grille répétitive mais visuellement cohérente. 4 lignes de code pour remplacer tout le système de paramètres biomiques de Minecraft vanilla.

getHeightAtFromHash : l'assembleur de terrain

La fonction au cœur de la génération combine les 4 coins seedés par biome :

// worldgen.c (simplifié)

static uint8_t getHeightAtFromHash (int rx, int rz, short cx, short cz,
                                    uint32_t h, uint8_t biome) {
  // 4 coins extraits du hash, seed différente par coin
  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);

  // Contrainte biome : désert jamais sous l'eau
  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);
  }

  // Interpolation depuis les 4 coins
  return interpolate(h1, h2, h3, h4, rx, rz);
}

Chaque biome a une biome_base qui décale la hauteur de référence, et les 4 coins sont extraits du hash avec des décalages différents. Le désert force le min au-dessus du niveau de la mer -- une ligne de contrainte qui évite l'eau sans calcul biomique supplémentaire.

Arbres et cactus : placement probabiliste

La génération de surface utilise le même hash de chunk pour décider où planter :

// worldgen.c (simplifié)

static void genFoliage (uint8_t *chunk_data, short cx, short cz,
                        uint32_t hash, uint8_t biome) {
  if (biome == BIOME_DESERT) {
    // Cactus : un candidat par chunk, hash détermine la 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 {
    // Arbres : hash détermine si on en pose et où
    int tree_count = (hash & 3);  // 0-3 arbres par 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 arbres par chunk pour les biomes verts, 1 cactus maximum pour le désert. Le hash du chunk est la seule source d'entropie -- un & 7 pour la position dans le chunk, un & 3 pour le compteur. Tout est déterministe, rien n'est stocké.

generateChunk : tout assembler

La fonction qui met tout ensemble pour produire un chunk complet de 8×8×256 blocs :

// worldgen.c (simplifié)

void generateChunk (uint8_t *chunk, short cx, short cz) {
  uint32_t hash = getChunkHash(cx, cz);
  uint8_t biome = getChunkBiome(cx, cz);

  // Pour chaque colonne du chunk (8×8 = 64)
  for (int x = 0; x < 8; x ++) {
    for (int z = 0; z < 8; z ++) {
      // Coordonnées monde absolues
      int wx = cx * 8 + x;
      int wz = cz * 8 + z;

      // Hauteur de la colonne
      uint8_t height = getHeightAt(wx, wz);

      // Remplir la colonne de bas en haut
      for (int y = 0; y < height; y ++) {
        uint8_t block = getTerrainBlock(wx, y, wz);
        chunk[y * 64 + z * 8 + x] = block;
      }
    }
  }

  // Ajouter les éléments de surface (arbres, cactus)
  genFoliage(chunk, cx, cz, hash, biome);
}

C'est tout. 3 boucles imbriquées : pour chaque colonne, trouver la hauteur, remplir les blocs, passer à la suivante. La sortie est un uint8_t[16384] (8 × 8 × 256) qui représente le chunk complet. Pas de caching, pas de lazy loading, pas de compression -- le chunk est généré et envoyé direct au client.

Le stockage : des arrays statiques partout

L'architecture mémoire de Bareiron, c'est du C embarqué dans toute sa splendeur. Pas de malloc, pas de hash maps, pas de listes chaînées.

Tout est dans des tableaux globaux de taille fixe.

Les changements de blocs

// globals.h, lignes 191-196

typedef struct {
  short x;      // 2 bytes -- limite à 32 000 blocs horizontal
  short z;      // 2 bytes
  uint8_t y;    // 1 byte -- limite à 256 blocs vertical
  uint8_t block; // 1 byte -- limite à 256 types de blocs
} BlockChange;

20 000 entrées, soit environ 25 000 changements -- l'équivalent d'un chunk et demi entièrement déterré. Le champ block à 0xFF marque une entrée libre. La recherche est une scan linéaire :

Layout mémoire du tableau de blocs -- 6 bytes par entrée

// 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;
}

Ajouter un changement est aussi direct que la recherche :

```c
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 ++;
}

Un compteur, un index, une écriture. Pas de tri, pas de compaction, pas de gestion mémoire. Quand le tableau est plein, les nouveaux changements sont ignorés -- le terrain revient à son état généré.

Le commentaire de l'auteur sur la limite à 256 blocs : "je compte pas implémenter les escaliers en cuivre légèrement patinés cirés de si tôt."

Les mobs : 8 bytes par tête de pipe

// globals.h, lignes 240-251 (pragma pack(push, 1) pour éliminer le padding)

typedef struct {
  uint8_t type;   // 25=chicken, 28=cow, 95=pig, 106=sheep, 145=zombie
  short x;
  uint8_t y;      // si health=0, Y devient un timer avant suppression
  short z;
  uint8_t data;   // bits 0-4: health, bit 5: sheep sheared, bits 6-7: panic timer
} MobData;

8 bytes. 16 emplacements max. Pas d'alignement, pas de padding. Le data byte est un bitfield maison : 5 bits de vie, 1 bit de tonte, 2 bits de timer de panique. Et quand un mob meurt, le champ Y devient un timer avant suppression. Réutilisation de mémoire au niveau du bit.

Les joueurs : packés serré

Les données joueurs utilisent #pragma pack(push, 1) aussi -- coordonnées en short + uint8_t, inventaires en tableaux fixes de uint16_t + uint8_t, et un champ flags qui encode à la fois le cooldown d'attaque, l'état de spawn, sneak, sprint, eat, load, movement cooldown, et le lock de craft. Tout ça dans des bits individuels.

La boucle principale : while(true) et du non-bloquant

Le serveur entier tourne sur une boucle, un thread, zéro event library.

// main.c, lignes 594-720

while (true) {
  task_yield();  // laisse respirer le watchdog sur ESP32

  // Accepter une nouvelle connexion (non-bloquant)
  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;
  }

  // Tick serveur si le temps est écoulé
  if (get_program_time() - last_tick_time > TIME_BETWEEN_TICKS) {
    handleServerTick(time_since_last_tick);
    last_tick_time = get_program_time();
  }

  // Round-robin : un client, un packet par itération
  client_index = (client_index + 1) % MAX_PLAYERS;
  if (clients[client_index] == -1) continue;

  // Lire l'entête du packet : 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);
}

Un seul client est traité par itération de la boucle, et un seul packet est lu à la fois. Le task_yield() au début de la boucle laisse le FreeRTOS idle task respirer sur ESP32 -- sans ça, le watchdog timer te reset la puce.

Le dispatch des packets, c'est un switch monstrueux de 400 lignes :

// main.c, lignes 68-497

void handlePacket (int client_fd, int length, int packet_id, int state) {
  switch (packet_id) {
    case 0x00:  // Handshake / Status / Login selon l'état
    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 (gère les coffres)
    case 0x19:  // Interact entity
    case 0x1D..0x20:  // Movement packets (le plus gros cas)
    case 0x28:  // Player action (dig/place)
    // ... 40+ cas
  }
}

Pas de jump table dynamique, pas de vtable, pas de map. Un switch compile en jump table statique. Parfait pour de l'embarqué.

Le cas 0x1D-0x20 est le plus gros -- il gère les mises à jour de position, les dégâts de chute, les traversées de frontières de chunk, le spawn de mobs, la génération de chunks, ET la faim. Tout en un seul gros fall-through.

Le code du serveur Bareiron -- 6800 lignes de C

Le serveur tick et l'IA des mobs

La fonction handleServerTick est appelée toutes les 50 ms (20 TPS). Elle gère le monde pendant que la boucle principale s'occupe des joueurs :

// main.c (simplifié)

void handleServerTick (uint32_t delta) {
  // Mettre à jour chaque mob
  for (int i = 0; i < MOB_COUNT; i ++) {
    if (mobs[i].type == 0 || mobs[i].data == 0) continue;  // mort ou vide

    MobData *mob = &mobs[i];
    int px, pz;
    getNearestPlayer(mob->x, mob->z, &px, &pz);

    if (mob->type == MOB_ZOMBIE) {
      // Hostile : marche vers le joueur le plus proche
      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 ++;
      // Dégâts de contact à 2 blocs
      if (abs(px - mob->x) <= 2 && abs(pz - mob->z) <= 2)
        damagePlayer(getNearestPlayerId(mob->x, mob->z), 3);
    } else {
      // Passif : 8 directions aléatoires
      uint8_t dir = getMobDir(mob);
      mob->x += dir_lookup[dir][0];
      mob->z += dir_lookup[dir][1];
      // Changement de direction toutes les ~40 ticks
      if (mob->data >> 6 < 1) setMobDir(mob, rand() & 7);
      mob->data = (mob->data & 0x3F) | ((mob->data - 0x40) & 0xC0);
    }

    // Réveiller les chunks autour du mob
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

L'IA des mobs hostiles, c'est une comparaison de coordonnées. Littéralement if (px < x) x--. Pas de pathfinding, pas de A*, pas d'obstacle avoidance. Le zombie ajuste X et Z indépendamment vers le joueur -- il traverse les murs si y'en a.

Les dégâts de contact sont à 3 coeurs/sec. p2r3 l'a voulu élevé parce que l'absence de pathfinding rend les zombies faciles à kiter.

La formule d'armure est celle d'avant le combat update -- la plus simple possible :

// main.c (simplifié)

static uint8_t applyArmor (uint8_t damage, uint16_t armor_value) {
  // Formule pré-1.9 : réduction linéaire
  // Chaque point d'armure = 4% de réduction, max 80%
  uint8_t reduction = (armor_value * 4);
  if (reduction > 80) reduction = 80;
  return damage * (100 - reduction) / 100;
}

Full diamond = 80% de réduction. Un coup de zombie à 3 coeurs devient 0.6 coeurs. p2r3 a choisi cette vielle formule parce qu'elle se calcule en 2 opérations -- pas de seuils, pas de courbes, juste un pourcentage linéaire.

Les mobs passifs : 8 directions dans une lookup table, changement de cap toutes les ~40 ticks. Le champ data encode la direction en cours dans les 2 bits de poids fort, et le timer de changement de direction dans les 6 bits restants.

Mobs dans Bareiron -- zombies, cochons, moutons

Le respawn des mobs

Les mobs ne spawnent pas avec des random ticks. Ils apparaissent quand le serveur tick rencontre une nouvelle frontière de chunk :

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;
  }
}

Même RNG que le terrain, même seed de chunk. Si un emplacement mob est libre, le spawn est déterministe.

Le craft : pas de matrices, du if/else

// crafting.c, lignes 9-347 (simplifié)

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // Si le flag 0x80 est levé, le buffer de craft est utilisé par un coffre
  if (player->flags & 0x80) { *count = 0; *item = 0; return; }

  // Compter les slots, trouver le premier item, vérifier l'identité
  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:  /* planches, lingots... */
    case 2:  /* bâtons, cisailles, torches */
    case 3:  /* pelles, épées, dalles */
    case 4:  /* table de craft, boots */
    case 5:  /* pioches, haches, casques */
    case 7:  /* jambières, composteurs */
    case 8:  /* fourneau, coffre, plastron */
    case 9:  /* blocs complets (fer, or, etc.) */
  }
}

Le premier check : si le flag 0x80 est levé, le buffer de craft est recyclé en pointeur de coffre. Pas de craft possible.

Ensuite, il compte les slots remplis, note le premier item, vérifie l'identité. Avec juste ça, tu mathes le fourneau en 4 checks :

if (count == 8 && first == cobblestone && all_identical && center_empty)
    return furnace;

Pour les formes complexes, il utilise l'index du premier item et check la position relative. Les recettes partagent une même fonction de matching -- le matériau détermine le résultat.

Interface de craft et coffre dans Bareiron

Les coffres : le hack en vrai

Le hack mémoire dont tout le monde parle, en vrai code :

// procedures.c, lignes 1262-1293

if (target == B_chest) {
  // Chercher l'entrée du coffre dans le tableau des blocs
  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]);  // pointe après le bloc coffre
    break;
  }
  if (storage_ptr == NULL) return;

  // Terrible memory hack!!
  // On copie le POINTEUR dans le tableau d'items de craft du joueur
  memcpy(player->craft_items, &storage_ptr, sizeof(storage_ptr));
  player->flags |= 0x80;  // lock le craft

  // Envoyer l'interface coffre au 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);
  }
}

Et le commentaire dans le code : // Terrible memory hack!!1!

C'est exactement ça. Il prend l'adresse mémoire de l'entrée suivante dans block_changes[], il la copie dans player->craft_items (qui est un uint16_t[9], donc 18 bytes -- assez pour stocker un pointeur 32 bits), et il lève le flag pour que personne essaie de craft pendant ce temps.

Sur chaque clic dans l'inventaire du coffre :

// packets.c, lignes 620-638

uint8_t *storage_ptr;
memcpy(&storage_ptr, player->craft_items, sizeof(storage_ptr));
// storage_ptr pointe maintenant vers les données du coffre
uint16_t *p_item = (uint16_t *)(storage_ptr + (slot - 41) * 3);
uint8_t *p_count = storage_ptr + (slot - 41) * 3 + 2;

Il récupère le pointeur depuis le buffer de craft, et il accède aux slots avec un offset. Les données coffre sont stockées à raison de 3 bytes par slot (2 pour l'ID, 1 pour la quantité), collées les unes aux autres dans le tableau de blocs.

Données de coffre stockées dans le tableau de blocs -- un hack mémoire

La faim : 5 lignes de génie

// main.c, lignes 293-305

// Les joueurs envoient des packets de mouvement à ~20/sec quand ils
// bougent, beaucoup moins quand ils sont immobiles. On corrèle ça
// avec l'activité pour simuler la faim gratuitement.
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;
}
}

C'est littéralement ça. 5 lignes. Chaque packet de mouvement décrémente la saturation. Quand la saturation arrive à zéro, la faim baisse et on reset la saturation. Le sprint (flag `0x08`) double le drain.

Zéro timer, zéro mémoire allouée, zéro compute dédié. Un compteur qui se décrémente sur des packets qui existent déjà.

### Les dégâts de chute

Le système de dégâts le plus simple du projet :

```c
// Quand le joueur quitte le sol, on stocke son Y
// Quand il retouche le sol, on soustrait
degats = dernier_y_au_sol - y_actuel;

Une soustraction.

Miner et placer des blocs

Quand tu cliques sur un bloc, le packet 0x28 (Player Action) atterrit dans le switch. Le handler doit déterminer quel bloc se trouve à la position, le retirer, et mettre l'item dans l'inventaire :

// main.c, case 0x28 (simplifié)

void handlePlayerAction (int client_fd, uint8_t action, int x, uint8_t y, int z) {
  switch (action) {
    case START_DESTROY_BLOCK: {
      // Déterminer le type de bloc à la position cliquée
      uint8_t block = getBlockAt(x, y, z);

      if (block == B_chest) {
        openChest(client_fd, x, y, z);
        break;
      }

      // Ajouter aux block_changes
      addBlockChange(x, z, y, 0);  // 0 = air

      // Donner l'item au joueur (trust the client)
      addItemToPlayer(client_fd, block_to_item(block), 1);

      // Envoyer la mise à jour au client
      sc_blockChange(client_fd, x, y, z, 0);
      sc_ackBlockChange(client_fd, x, y, z, 0);
      break;
    }
    case PLACE_BLOCK: {
      // Lire le type de bloc depuis la main du joueur
      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 combine la génération de terrain ET les changements joueurs :

uint8_t getBlockAt (int x, uint8_t y, int z) {
  // D'abord vérifier les changements joueurs
  uint8_t change = getBlockChange(x, y, z);
  if (change != 0xFF) return change;

  // Sinon, lire depuis le terrain généré
  return getTerrainBlock(x, y, z);
}

Priorité aux changements, fallback sur le terrain. Zéro débat, zéro cache, zéro overhead. Le getTerrainBlock sous le capot, c'est getHeightAt + les couches de stone/dirt/grass/coal.

Le fourneau instantané

Le plus drôle : le fourneau n'existe pas en tant qu'entité. Si tu poses du cobblestone dans la case "cuisson" et du coal dans "fuel", le résultat apparaît immédiatement. Pas de timer, pas de chunk ticking. C'est juste un slot d'inventaire qui se vide quand tu mets les bons items.

Fourneau instantané -- pose les ingrédients, résultat immédiat

La boucle ESP32 : un serveur MC dans 4 KB de stack

// main.c, lignes 732-779

#ifdef ESP_PLATFORM

void bareiron_main (void *pvParameters) {
  main();
  vTaskDelete(NULL);
}

static void wifi_event_handler (...) {
  if (/* connecté */) {
    xTaskCreate(bareiron_main, "bareiron", 4096, NULL, 5, NULL);
  }
}

void app_main () {
  esp_timer_early_init();
  wifi_init();
  // Le reste est géré par le event handler
}
#endif

Le serveur entier tourne dans une tâche FreeRTOS avec 4096 bytes de stack. C'est tout. Le main thread principal ne fait qu'initialiser le WiFi et attendre une connexion. Une fois connecté, il spawn bareiron_main qui appelle le main() standard.

Tout le code spécifique ESP32 est protégé par des #ifdef ESP_PLATFORM. Sur PC, tout ça compile en code POSIX standard.

Ce qui a été sacrifié

Pour que tout ça tienne, y'a des features vanilla qui existent pas :

  • Pas de compression réseau -- zlib trop cher. Le serveur génère des chunks vite, mais les envoyer est le bottleneck.
  • Pas de random ticks -- les arbres poussent avec de la bone meal ou pas. Les mobs spawnent aux frontières de chunk.
  • Pas d'entités item -- les blocs minés vont direct dans l'inventaire. L'animation est purement visuelle.
  • Aucune vérification d'inventaire -- trust the client. 64 diamants ? OK. Un chunk miné en 1 sec ? OK. À utiliser entre gens de confiance.
  • Pas de lumière serveur -- les torches sont envoyées après tout le reste, le client calcule.
  • Pas de fluides progressifs -- état final instantané.

Le résultat final

Ryzen 5 3600 : ~0.5 ms par chunk. ESP32-C3 à 1$ : ~200 ms par chunk. Jouable.

Benchmark de génération de chunks -- Ryzen vs ESP32

3+ joueurs : ça rame. Comparable à 2b2t aux heures de pointe, dixit l'auteur.

Plusieurs joueurs connectés au même serveur Bareiron

La philosophie

p2r3 : "J'aime juste l'idée que cette toute petite puce à 1 balles qui consomme 0.5 Watt puisse faire tourner quelque chose d'aussi avancé que Minecraft. Science isn't about 'why', it's about 'why not'."

Chaque ligne est un tradeoff :

  • Perlin noise → interpolation : moins joli, 200x plus rapide, zéro mémoire
  • Matrices de craft → matching hardcodé : code dégueu, zéro byte
  • zlib → rien : connexion pourrie = mort, mais jouable
  • Validation → trust : zéro sécurité, zéro compute

Chaque feature absente permet à une autre d'exister dans les limites du hardware.

Les 3 trucs à retenir :

  1. Interpolation + RNG -- 4 points seedés, terrain infini, zéro stockage, query sans regénérer le chunk, 200 ms de génération. C'est le move de génie qui rend tout le reste possible.
  2. Chaque feature a un coût -- Pas de compression, pas de random ticks, pas de validation. C'est pas des oublis, c'est ce qui permet de tenir dans 520 KB.
  3. Les hacks dégueus sont les plus intelligents -- Coffres dans le tableau de blocs via memcpy, faim par packets de mouvement, fourneau instantané. La solution propre aurait été trop chère.

Si le projet t'intéresse, tout est sur GitHub en GPLv3. C'est du C bien sale, et j'ai rarement pris autant de plaisir à lire un code source xD

Bareiron----在1美元的微控制器上运行的Minecraft服务器

6800行C代码,零malloc,用双线性插值替代Perlin噪声,瓦片地图式的生物群系,全都跑在1美元的芯片上。

引言

你有没有想过,能不能在一块钱的微控制器上跑一个Minecraft服务器?

我想过。答案是:能。字面意义上的能。

有个叫 Bareiron 的项目,作者是 p2r3,这大概是我近几年在Minecraft世界里见过最酷的项目之一。一个二进制文件只有 300KB,6800行C代码,零外部依赖,没有 malloc,没有多线程,跑在 1美元的 ESP32 上。

ESP32-C3,运行服务器的微控制器

无限地形生成。生物群系。洞穴。合成。挖矿。生物。饥饿值。箱子。生存服务器该有的全都有。

在一块功耗 0.5瓦、主频 160 MHz 的芯片上。

给你个概念:原版Minecraft服务器需要几个GB的内存。ESP32-C3 只有 520 KB SRAM(启动后可用约 400 KB)。20年前的处理器都已经跑在 GHz 级别了----这个最高只有 160 MHz。两者在纯算力上的差距大约是 20,000 倍。

p2r3 不是用 C 语言重写了一个 Minecraft 服务器,他 reinvent 了服务器的每一块积木,让所有东西都能塞进这些限制里。我们打开源代码来看看他是怎么做的。

p2r3 的 Bareiron 演示视频缩略图

项目的核心:零内存的地形生成

要做嵌入式 MC 服务器,最大的问题就是地形生成。

在原版 Minecraft 中,世界是用 Perlin 噪声生成的:多层叠加(倍频程),6 个生物群系参数(温度、湿度、大陆性、侵蚀、怪异度、深度),还有一整套缓存系统,免得每次都重新计算。

结果确实很美。但计算成本高,还要占用 RAM 来存储已生成的区块。

Bareiron 的做法则完全不同。它不叠加噪声,而是用双线性插值处理由确定性 RNG 生成的 4 个点。

你知道那种把小像素图放大后边缘变模糊的效果吗?就是这个道理。

// worldgen.c,第 117-171 行(简化)

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);
  // 在由 hash + biome 播种的 4 个角之间插值
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

标准的双线性插值:4 个角点,根据位置加权,输出一个 uint8_t。CHUNK_SIZE 是 8,所以全程整数乘法,没有浮点运算。

p2r3 在视频里一步步展示:先是区块的 4 个角点,每个角点的高度由 RNG 播种决定。

区块的 4 个角点,每个由确定性 RNG 播种

然后在这 4 个点之间插值,形成一个连续的地表。

在 4 个角点之间应用双线性插值

在所有相邻区块上重复这个模式,就能得到无限延伸的地形。

最终结果:连续的不规则地形

确定性 RNG

之所以能做到这一切,关键在于播种。每个区块有 4 个角点,每个角点都需要一个唯一但可复现的伪随机值。

// worldgen.c,第 13-22 行

uint32_t getChunkHash (short x, short z) {
  uint8_t buf[8];
  memcpy(buf, &x, 2);          // 16 位 X 坐标
  memcpy(buf + 2, &z, 2);      // 16 位 Z 坐标
  memcpy(buf + 4, &world_seed, 4);  // 32 位全局种子
  return splitmix64(*((uint64_t *)buf));  // 哈希
}

他把 X 的 16 位、Z 的 16 位和种子的 32 位打包进一个 8 字节的缓冲区,然后全部喂给 splitmix64。结果:基于世界种子,每个位置对应一个唯一且确定性的值。

你能感受到这有多强吗?服务器不需要存储地形。当玩家进入新区域时,它即时重新计算,而且每次结果完全一样。

用到的 splitmix64 是一种专为 64 位哈希设计的超快 PRNG:

// worldgen.c(简化)

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 步操作:加法、异或/移位、乘法、异或/移位、乘法、异或/移位。没有查找表,没有循环。它把 8 字节缓冲区(X + Z + 种子)当作一个 64 位整数处理,返回 32 位哈希值。确定性、快速,5 行代码搞定。

为什么这不是 Perlin 噪声

p2r3 在视频里自己说了:"你取的随机数 digits 越多,地形就越规整,就像投硬币次数越多就越接近 50/50"。实际就是看他组合了多少位哈希值:

// worldgen.c,第 51-115 行

// 对于 plains 生物群系:组合 4 个因子 → 地形平坦
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// 对于 snowy plains:2 个因子 → 更崎岖
h = (hash % 5) + ((hash >> 4) % 5);

每个生物群系选择组合多少次位提取。次数越多,分布越稳定----就像投硬币次数越多越接近 50/50。次数越少,局部变化越剧烈。

不规则地形----少因子、强变化

只用 2 个因子,雪原产生起伏的地形,几乎是山地。高峰和低谷很密集。

平坦地形----多因子、平滑表面

用 4 个因子,平原保持平坦和可预测。分布稳定下来。

一个区块在 ESP32 上生成只需 200 毫秒----而在同样硬件上用 Perlin 噪声,计算成本高到无法测量。

最致命的细节:查询方块而不生成整个区块

你在玩,你在挖一个方块。服务器需要知道该给你什么物品。天真的做法是先生成整个区块。

而用双线性插值,你可以直接从坐标查询平面上的任意一个点。区块的角点从玩家位置获取,插值给你任意偏移处的高度。只需少量数学运算,无需生成区块。

p2r3:"我想要的是一个神奇的函数,它能告诉我给定坐标处有什么方块,而不需要访问内存或计算昂贵的噪声图"。他确实做到了。

下面是高度如何变成具体的方块:

// worldgen.c(简化)

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 个条件判断。一层层的 grass/dirt/stone/deepslate/bedrock。地表方块取决于生物群系,通过 biome_top[] 获取----平原是 grass,沙漠是 sand。没有循环,没有 switch,只有一串 if 落到对应层。

洞穴,最懒的镜像

洞穴高度 = CAVE_BASE_DEPTH - (地表高度 - y);

他把地表高度镜像到地下。看起来像深板岩大洞穴。零计算量,一行搞定。

通过镜像地表地形生成的洞穴

地形镜像生成洞穴的示意图

矿物,XOR 版

候选值 = (chunk_x ^ col_x ^ col_z) % 100;
if (候选值 < 5 && y < 16) -> 钻石

坐标的 XOR 确保每列只有一个候选。类型完全取决于高度。钻石藏在洞穴最低点以下,这样挖矿仍然有意义。

瓦片地图式的生物群系

每个生物群系是网格中的一个圆形岛屿,其类型由基于种子的计算模式决定。网格化、可预测、零成本。

瓦片地图式的生物群系地图----每个岛屿是一个不同的生物群系

每个生物群系有自己的一套参数,编码在数组中:

// worldgen.c(简化)

static const uint8_t biome_base[] = {
  [BIOME_PLAINS]  = 48,   // 基础高度:48
  [BIOME_DESERT]  = 52,   // 略高
  [BIOME_FOREST]  = 50,   // 居中
  [BIOME_TAIGA]   = 46,   // 略低
  [BIOME_SNOWY]   = 40,   // 最低
};

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 次提取 → 非常平坦
  [BIOME_DESERT]  = 3,   // 3 次提取 → 中等
  [BIOME_FOREST]  = 4,   // 4 次提取 → 平坦起伏
  [BIOME_TAIGA]   = 3,   // 3 次提取 → 中等
  [BIOME_SNOWY]   = 2,   // 2 次提取 → 非常崎岖
};

平原:高度 48,4 个因子 → 非常平坦的地形,草地。

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// 结果:最大 ±4 格变化

沙漠:高度 52,3 个因子,地表方块 = 沙子。永远不会低于海平面。

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// 结果:最大 ±6 格变化,限制不低于 SEA_LEVEL+1

森林:高度 50,4 个因子,和 plains 一样但基础更高 → 树木繁茂的丘陵。

针叶林:高度 46,3 个因子 → 中等变化,寒冷地形。

雪原:高度 40,只有 2 个因子 → 最崎岖。

h = (hash % 5) + ((hash >> 4) % 5);
// 结果:最大 ±14 格变化

每个生物群系编码在 3 个各含 5 个条目的数组中:基础高度、地表方块、因子数量。当 getHeightAtFromHash 收到生物群系时,它查询这些数组来调整地形。只用 15 字节的数据就替代了整个 Minecraft 生物群系系统。

生物群系检测器用种子来确定每个区块对应的生物群系:

// worldgen.c(简化)

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];
}

一个 16 个条目的模式,索引由区块坐标播种。这产生了一个重复但视觉上一致的网格。4 行代码就替代了整个原版 Minecraft 的生物群系参数系统。

getHeightAtFromHash:地形组装器

生成核心函数结合了由生物群系播种的 4 个角点:

// worldgen.c(简化)

static uint8_t getHeightAtFromHash (int rx, int rz, short cx, short cz,
                                    uint32_t h, uint8_t biome) {
  // 从哈希中提取 4 个角点,每角不同种子
  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);

  // 生物群系约束:沙漠永不在水下
  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);
  }

  // 从 4 个角点进行插值
  return interpolate(h1, h2, h3, h4, rx, rz);
}

每个生物群系有一个 biome_base 来偏移参考高度,4 个角点从哈希中按不同偏移提取。沙漠强制最小值高于海平面----一行约束条件,不需要额外的生物群系计算。

树木和仙人掌:概率性放置

地表生成使用同样的区块哈希来决定在哪里种植:

// worldgen.c(简化)

static void genFoliage (uint8_t *chunk_data, short cx, short cz,
                        uint32_t hash, uint8_t biome) {
  if (biome == BIOME_DESERT) {
    // 仙人掌:每区块一个候选,哈希决定位置
    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 {
    // 树木:哈希决定是否放置和放哪里
    int tree_count = (hash & 3);  // 每区块 0-3 棵树
    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 棵树,沙漠最多 1 个仙人掌。区块的哈希是唯一的熵源----& 7 确定区块内的位置,& 3 确定数量。一切都是确定性的,什么都不存储。

generateChunk:组装在一起

把所有东西组合起来,生成一个完整的 8×8×256 区块的函数:

// worldgen.c(简化)

void generateChunk (uint8_t *chunk, short cx, short cz) {
  uint32_t hash = getChunkHash(cx, cz);
  uint8_t biome = getChunkBiome(cx, cz);

  // 遍历区块的每一列(8×8 = 64)
  for (int x = 0; x < 8; x ++) {
    for (int z = 0; z < 8; z ++) {
      // 世界绝对坐标
      int wx = cx * 8 + x;
      int wz = cz * 8 + z;

      // 该列的高度
      uint8_t height = getHeightAt(wx, wz);

      // 从下往上填充该列
      for (int y = 0; y < height; y ++) {
        uint8_t block = getTerrainBlock(wx, y, wz);
        chunk[y * 64 + z * 8 + x] = block;
      }
    }
  }

  // 添加地表元素(树木、仙人掌)
  genFoliage(chunk, cx, cz, hash, biome);
}

就这些。3 个嵌套循环:对每一列,找到高度,填充方块,继续下一列。输出是一个 uint8_t[16384](8 × 8 × 256),代表完整的区块。没有缓存,没有懒加载,没有压缩----区块生成后直接发送给客户端。

存储:到处都是静态数组

Bareiron 的内存架构,就是嵌入式 C 的最佳体现。没有 malloc,没有哈希表,没有链表。

所有东西都在固定大小的全局数组中。

方块变更

// globals.h,第 191-196 行

typedef struct {
  short x;      // 2 字节 -- 水平方向限制在 32,000 格
  short z;      // 2 字节
  uint8_t y;    // 1 字节 -- 垂直方向限制在 256 格
  uint8_t block; // 1 字节 -- 限制在 256 种方块类型
} BlockChange;

20,000 个条目,大约 25,000 次变更----相当于一个半区块被完全挖空。block 字段为 0xFF 表示空闲条目。查找是线性扫描:

方块数组的内存布局----每条目 6 字节

// 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;
}

添加变更和查找一样直接:

```c
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 ++;
}

一个计数器,一个索引,一次写入。没有排序,没有压缩,没有内存管理。当数组满了,新的变更被忽略----地形恢复为生成状态。

作者对 256 种方块限制的评论:"我短期内不打算实现轻微氧化抛光铜楼梯。"

生物:每条命 8 字节

// globals.h,第 240-251 行(pragma pack(push, 1) 消除填充)

typedef struct {
  uint8_t type;   // 25=鸡, 28=牛, 95=猪, 106=羊, 145=僵尸
  short x;
  uint8_t y;      // 如果 health=0,Y 变成删除前的计时器
  short z;
  uint8_t data;   // bit 0-4: 生命值, bit 5: 羊是否剪过毛, bit 6-7: 恐慌计时器
} MobData;

8 字节。最多 16 个位置。没有对齐,没有填充。data 字节是一个自制的位字段:5 位生命值,1 位剪毛标记,2 位恐慌计时器。当生物死亡时,Y 字段变成删除前的计时器。这是位级别的内存复用。

玩家:紧密打包

玩家数据也用了 #pragma pack(push, 1)----坐标用 short + uint8_t,背包用 uint16_t + uint8_t 的固定数组,还有一个 flags 字段,同时编码了攻击冷却、生成状态、潜行、冲刺、进食、加载、移动冷却和合成锁定。所有这些都塞在独立的位里。

主循环:while(true) + 非阻塞

整个服务器跑在一个循环里,一个线程,零事件库。

// main.c,第 594-720 行

while (true) {
  task_yield();  // 让 ESP32 的看门狗喘口气

  // 接受新连接(非阻塞)
  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;
  }

  // 如果时间到了,执行服务器 tick
  if (get_program_time() - last_tick_time > TIME_BETWEEN_TICKS) {
    handleServerTick(time_since_last_tick);
    last_tick_time = get_program_time();
  }

  // 轮询:每轮一个客户端,一个数据包
  client_index = (client_index + 1) % MAX_PLAYERS;
  if (clients[client_index] == -1) continue;

  // 读取数据包头部:长度 + 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);
}

每次循环迭代只处理一个客户端,每次只读一个数据包。循环开头的 task_yield() 让 FreeRTOS 的空闲任务能在 ESP32 上喘口气----没有这个,看门狗定时器会重置芯片。

数据包分发是一个 400 行的巨型 switch:

// main.c,第 68-497 行

void handlePacket (int client_fd, int length, int packet_id, int state) {
  switch (packet_id) {
    case 0x00:  // 根据状态:握手 / 状态 / 登录
    case 0x01:  // 状态 ping
    case 0x02:  // 插件消息
    case 0x03:  // 登录/配置确认
    case 0x08:  // 聊天
    case 0x0B:  // 客户端状态(重生)
    case 0x11:  // 点击容器(处理箱子)
    case 0x19:  // 与实体交互
    case 0x1D..0x20:  // 移动数据包(最大分支)
    case 0x28:  // 玩家动作(挖掘/放置)
    // ... 40+ 个 case
  }
}

没有动态跳转表,没有 vtable,没有映射。switch 编译成静态跳转表。嵌入式系统的完美方案。

0x1D-0x20 分支是最大的----它处理位置更新、坠落伤害、区块边界穿越、生物生成、区块生成,还有饥饿值。全部集中在一个大 fall-through 里。

Bareiron 服务端代码 -- 6800 行 C

服务器 tick 与生物 AI

handleServerTick 函数每 50 毫秒(20 TPS)被调用一次。它在主循环处理玩家的同时管理世界:

// main.c(简化)

void handleServerTick (uint32_t delta) {
  // 更新每个生物
  for (int i = 0; i < MOB_COUNT; i ++) {
    if (mobs[i].type == 0 || mobs[i].data == 0) continue;  // 死亡或空位

    MobData *mob = &mobs[i];
    int px, pz;
    getNearestPlayer(mob->x, mob->z, &px, &pz);

    if (mob->type == MOB_ZOMBIE) {
      // 敌对:走向最近的玩家
      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 ++;
      // 2 格内接触伤害
      if (abs(px - mob->x) <= 2 && abs(pz - mob->z) <= 2)
        damagePlayer(getNearestPlayerId(mob->x, mob->z), 3);
    } else {
      // 被动:8 个随机方向
      uint8_t dir = getMobDir(mob);
      mob->x += dir_lookup[dir][0];
      mob->z += dir_lookup[dir][1];
      // 每 ~40 tick 改变方向
      if (mob->data >> 6 < 1) setMobDir(mob, rand() & 7);
      mob->data = (mob->data & 0x3F) | ((mob->data - 0x40) & 0xC0);
    }

    // 唤醒生物周围的区块
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

敌对生物的 AI 就是坐标比较。字面意义上的 if (px < x) x--。没有寻路,没有 A*,没有避障。僵尸独立地向玩家调整 X 和 Z----有墙也穿墙。

接触伤害是 3 颗心/秒。p2r3 故意设置得高,因为缺乏寻路使得僵尸很容易被风筝。

护甲公式用的是战斗更新前的版本----最简单的方案:

// main.c(简化)

static uint8_t applyArmor (uint8_t damage, uint16_t armor_value) {
  // 1.9 之前的公式:线性减免
  // 每点护甲值 = 4% 减免,最高 80%
  uint8_t reduction = (armor_value * 4);
  if (reduction > 80) reduction = 80;
  return damage * (100 - reduction) / 100;
}

全套钻石 = 80% 减免。僵尸 3 心的一击变成 0.6 心。p2r3 选择这个旧公式是因为它只需 2 步运算----没有阈值,没有曲线,就是线性百分比。

被动生物:从查找表中取 8 个方向,每 ~40 tick 改变方向。data 字段在前 2 个高位 bit 中编码当前方向,在剩余 6 位中编码方向改变计时器。

Bareiron 中的生物----僵尸、猪、羊

生物重生

生物不是通过随机 tick 生成的。它们是在服务器 tick 发现新的区块边界时出现的:

if (玩家越过区块边界) {
  for (int i = 0; i < MOB_COUNT; i ++) {
    if (mobs[i].type != 0) continue;
    spawnMob(&mobs[i], new_chunk_coords, getChunkHash(cx, cz));
    break;
  }
}

和地形用同一个 RNG,同一个区块种子。如果生物位置有空位,生成就是确定性的。

合成:没有配方矩阵,只有 if/else

// crafting.c,第 9-347 行(简化)

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // 如果 flag 0x80 被设置,合成缓冲区被箱子占用
  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:  /* 木板、锭... */
    case 2:  /* 木棍、剪刀、火把 */
    case 3:  /* 锹、剑、台阶 */
    case 4:  /* 合成台、靴子 */
    case 5:  /* 镐、斧、头盔 */
    case 7:  /* 护腿、堆肥桶 */
    case 8:  /* 熔炉、箱子、胸甲 */
    case 9:  /* 完整方块(铁、金等) */
  }
}

第一个检查:如果 0x80 标志被设置,合成缓冲区被复用为箱子指针。不能合成。

然后,它统计已填充的格子数,记下第一个物品,检查是否全部相同。仅凭这些,4 个检查就能匹配熔炉:

if (count == 8 && first == 圆石 && all_identical && center_empty)
    return 熔炉;

对于复杂形状,它用第一个物品的索引并检查相对位置。所有配方共享同一个匹配函数----材料决定结果。

Bareiron 中的合成和箱子界面

箱子:真正的 hack

广为流传的内存 hack,实际代码是这样的:

// procedures.c,第 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);
  }
}

代码里的注释:// Terrible memory hack!!1!

就是这样。它取 block_changes[] 中下一条目的内存地址,复制到 player->craft_items(一个 uint16_t[9],即 18 字节----足以存储 32 位指针),然后设置标志,防止任何人在这期间尝试合成。

每次点击箱子背包:

// packets.c,第 620-638 行

uint8_t *storage_ptr;
memcpy(&storage_ptr, player->craft_items, sizeof(storage_ptr));
// storage_ptr 现在指向箱子数据
uint16_t *p_item = (uint16_t *)(storage_ptr + (slot - 41) * 3);
uint8_t *p_count = storage_ptr + (slot - 41) * 3 + 2;

它从合成缓冲区取出指针,用偏移量访问格子。箱子数据以每格 3 字节(2 字节 ID,1 字节数量)的方式存储在方块数组中,彼此紧挨。

箱子数据存储在方块数组中----一个内存 hack

饥饿值:5 行天才代码

// main.c,第 293-305 行

// 玩家移动时以 ~20/秒发送移动数据包,
// 静止时少得多。我们把这与活动关联起来,
// 免费模拟饥饿值。
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) {  // 冲刺
  player->saturation -= 1;
}

字面意义就是这 5 行。每个移动数据包减少饱和度。当饱和度降到零,饥饿值下降并重置饱和度。冲刺(标志 0x08)使消耗翻倍。

零计时器,零分配内存,零专用计算。只是一个计数器,在已经存在的数据包上递减。

坠落伤害

项目中最简单的伤害系统:

// 当玩家离开地面时,存储其 Y 值
// 当玩家再次碰触地面时,做减法
伤害 = 最后地面Y - 当前Y;

一个减法。

挖掘和放置方块

当你点击一个方块时,0x28(玩家动作)数据包进入 switch。处理程序需要确定该位置的方块类型,移除它,并把物品放入背包:

// main.c,case 0x28(简化)

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;
      }

      // 添加到 block_changes
      addBlockChange(x, z, y, 0);  // 0 = 空气

      // 给玩家物品(信任客户端)
      addItemToPlayer(client_fd, block_to_item(block), 1);

      // 向客户端发送更新
      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 结合了地形生成和玩家变更:

uint8_t getBlockAt (int x, uint8_t y, int z) {
  // 先检查玩家变更
  uint8_t change = getBlockChange(x, y, z);
  if (change != 0xFF) return change;

  // 否则从生成的地形读取
  return getTerrainBlock(x, y, z);
}

变更优先,兜底地形。零争议,零缓存,零开销。getTerrainBlock 底层是 getHeightAt 加上各层 stone/dirt/grass/coal。

即时熔炉

最好笑的是:熔炉作为实体根本不存在。如果你把圆石放进"烧炼"格,煤放进"燃料"格,结果会立即出现。没有计时器,没有区块 ticking。就只是一个背包格子,当你放入正确的物品时,它会清空自己。

即时熔炉----放入材料,立即出结果

ESP32 循环:4 KB 栈上的 MC 服务器

// main.c,第 732-779 行

#ifdef ESP_PLATFORM

void bareiron_main (void *pvParameters) {
  main();
  vTaskDelete(NULL);
}

static void wifi_event_handler (...) {
  if (/* 已连接 */) {
    xTaskCreate(bareiron_main, "bareiron", 4096, NULL, 5, NULL);
  }
}

void app_main () {
  esp_timer_early_init();
  wifi_init();
  // 其余由事件处理器管理
}
#endif

整个服务器跑在一个 FreeRTOS 任务里,只有 4096 字节的栈。就这些。主线程只负责初始化 WiFi 并等待连接。一旦连接上,它 spawn 出 bareiron_main,后者调用标准的 main()。

所有 ESP32 专用代码都用 #ifdef ESP_PLATFORM 保护。在 PC 上,全部编译为标准 POSIX 代码。

被牺牲的功能

为了让这一切能塞进去,有些原版功能不存在:

  • 无网络压缩----zlib 成本太高。服务器生成区块很快,但发送它们是瓶颈。
  • 无随机 tick----树要么用骨粉催熟,要么不熟。生物在区块边界生成。
  • 无掉落物实体----挖掉的方块直接进背包。动画纯粹是视觉上的。
  • 完全不做背包验证----信任客户端。64 颗钻石?OK。1 秒挖完一个区块?OK。只适合在互相信任的人之间玩。
  • 无服务端光照----火把在所有其他数据之后发送,由客户端计算。
  • 无渐进流体----直接跳到最终状态。

最终结果

Ryzen 5 3600:每个区块约 0.5 毫秒。 1 美元的 ESP32-C3:每个区块约 200 毫秒。可以玩。

区块生成基准测试----Ryzen vs ESP32

3 个以上玩家:开始卡了。作者说,堪比高峰时段的 2b2t。

多个玩家连接到同一个 Bareiron 服务器

背后的哲学

p2r3:"我就是喜欢这个想法----这块只有一美元、功耗 0.5 瓦的小芯片,能跑像 Minecraft 这么复杂的东西。Science isn't about 'why', it's about 'why not'."

每一行都是权衡取舍:

  • Perlin 噪声 → 插值:没那么好看,快 200 倍,零内存
  • 合成配方矩阵 → 硬编码匹配:代码丑,零字节
  • zlib → 什么都没有:连接差就会死,但能玩
  • 验证 → 信任:零安全,零计算

每一个缺失的功能,都是为了在硬件限制下让另一个功能得以存在。

3 个要点:

  1. 插值 + RNG----4 个播种点,无限地形,零存储,无需重新生成区块即可查询,200 毫秒生成。这是让所有其他东西成为可能的天才操作。
  2. 每个功能都有成本----无压缩、无随机 tick、无验证。这不是疏忽,这是能在 520 KB 内存里跑起来的原因。
  3. 最脏的 hack 就是最聪明的----通过 memcpy 把箱子存在方块数组中、用移动数据包模拟饥饿值、即时熔炉。干净的方案成本太高了。

如果你对这个项目感兴趣,所有代码都在 GitHub 上的 GPLv3。是写得挺脏的 C 代码,但我很少读源代码读得这么开心 xD

Bareiron -- 1ドルのマイコンで動くMinecraftサーバー

C言語6800行、mallocゼロ、パーリンノイズをバイリニア補間に置き換え、タイルマップ式バイオーム、すべて1ドルのチップで

はじめに

1ドルのマイコンでMinecraftサーバーが動かせるかどうか、考えたことある?

俺はある。そして答えはイエスだ。文字通り。

Bareironっていうプロジェクトがあって、p2r3作。ここ数年でMinecraft界で見た中で、おそらく最も魅力的なプロジェクトのひとつだ。300キロバイトのバイナリ、C言語6800行、外部依存ゼロ、mallocなし、スレッディングなし、それが1ドルのESP32で動く。

ESP32-C3, le microcontrôleur qui fait tourner le serveur

無限地形生成。バイオーム。洞窟。クラフト。採掘。モブ。空腹。チェスト。サバイバルサーバーに期待するすべての機能。

消費電力0.5ワット、クロック160 MHzのチップで。

イメージしてほしい:バニラのMinecraftサーバーは数ギガのRAMが必要だ。ESP32-C3は520 KBのSRAM(ブート後に使えるのは400)。20年前のプロセッサですでにギガヘルツで動いてた -- こいつは160 MHzが上限。純粋な演算性能の差は約20,000倍。

p2r3はCでMinecraftサーバーを書いたんじゃない。サーバーのすべての構成要素を、この制約に収まるように再発明したんだ。ソースコードを開いて、どうやってやったのか見てみよう。

Miniature de la vidéo de présentation de Bareiron par p2r3

プロジェクトの核心:メモリ無しの地形生成

組み込みMCサーバーを作るときの最大の問題は地形生成だ。

バニラMinecraftでは、ワールドはパーリンノイズで生成される:複数の重ね合わせレイヤー(オクターブ)、6つのバイオームパラメータ(温度、湿度、大陸性、侵食、変則性、深度)、そして毎回再計算しなくていいようにキャッシュシステム全体。

結果は美しい。でも計算コストが高いし、生成されたチャンクを保存するのにRAMを食う。

Bareironのアプローチは根本的に違う。ノイズを重ねる代わりに、決定論的RNGで生成した4点を使ったバイリニア補間を使う。

小さいピクセル画像を拡大したときに境界がぼやけるあの感じ、分かる?まさにあれだ。

// worldgen.c, lignes 117-171 (simplifié)

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);
  // hash + biomeでシードされた4隅の補間
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

標準的なバイリニア補間:4つの隅、位置に応じた重み、出力は単一のuint8_t。CHUNK_SIZEは8なので、整数の掛け算で済み、floatは使わない。

p2r3が動画で段階的に見せてる:まずチャンクの4隅、それぞれRNGでシードされた高さを持つ。

Les 4 coins du chunk, chacun seedé par le RNG déterministe

次に、この4点間の補間で連続したサーフェスができる。

Application de la bilinear interpolation entre les 4 coins

そしてこのパターンを隣接する全チャンクに繰り返せば、無限に広がる地形が得られる。

Résultat final : terrain irrégulier continu

決定論的RNG

これを可能にする鍵はシーディングだ。各チャンクには4つの隅があり、各隅にはユニークで再現可能な擬似乱数値が必要だ。

// worldgen.c, lignes 13-22

uint32_t getChunkHash (short x, short z) {
  uint8_t buf[8];
  memcpy(buf, &x, 2);          // X座標の16ビット
  memcpy(buf + 2, &z, 2);      // Z座標の16ビット
  memcpy(buf + 4, &world_seed, 4);  // グローバルシードの32ビット
  return splitmix64(*((uint64_t *)buf));  // ハッシュ
}

Xの16ビット、Zの16ビット、シードの32ビットを8バイトのバッファに詰め込んで、全部をsplitmix64に通す。結果:ワールドシードに基づく、各位置に対して一意で決定論的な値。

この凄さが分かるか?サーバーは地形を保存する必要がない。プレイヤーが新しいエリアに来たときにその場で再計算するだけで、毎回まったく同じ結果になるんだ。

使われているsplitmix64は64ビットハッシュ用に設計された超高速PRNGだ:

// worldgen.c (simplifié)

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つ:加算、xor/shift、乗算、xor/shift、乗算、xor/shift。ルックアップテーブルなし、ループなし。8バイトのバッファ(X + Z + シード)を64ビット整数として扱い、32ビットのハッシュを返す。決定論的で高速、たった5行。

なぜパーリンノイズじゃないのか

p2r3自身が動画で言ってる:「乱数の桁を増やすほど、地形は規則的になる。コイン投げの回数が増えるほど50/50に近づくのと同じだ」。実際には、組み合わせるハッシュのビット数のことだ:

// worldgen.c, lignes 51-115

// 平原バイオームの場合:4つの要素を組み合わせ → 規則的な地形
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// 氷雪平原の場合:2要素 → より起伏が激しい
h = (hash % 5) + ((hash >> 4) % 5);

各バイオームが、いくつのビット抽出を組み合わせるかを選ぶ。多ければ多いほど分布が安定する -- コイン投げの回数が増えて50/50に近づくのと同じ。少なければ少ないほど、局所的な変動が大きくなる。

Terrain irrégulier -- peu de facteurs, variations fortes

たった2要素で、氷雪平原は丘陵地帯、ほとんど山岳のような地形になる。ピークと窪みが頻繁に現れる。

Terrain régulier -- facteurs multiples, surface lisse

4要素では、平原は平坦で予測可能なまま。分布が安定する。

チャンク1つの生成にかかる時間はESP32で200 ms -- 同じハードウェアでパーリンノイズだとコストが高すぎて計測すらできない。

ヤバい詳細:チャンク全体を生成せずにブロックを参照

プレイしてて、ブロックを掘る。サーバーはどのアイテムをドロップするか知る必要がある。単純に考えれば、そのためにチャンク全体を生成する必要がある。

バイリニア補間を使えば、座標から直接任意の点を問い合わ合わせられる。チャンクの隅はプレイヤーの位置から取得でき、補間で任意のオフセットにおける高さが得られる。数回の算術演算だけで、チャンク生成は不要。

p2r3曰く:「俺が欲しかったのは、メモリにアクセスしたり高コストなノイズマップを計算したりせずに、指定された座標のブロックを教えてくれる魔法の関数だ」。まさにそれを実現した。

高さが具体的なブロックになる仕組みはこうだ:

// worldgen.c (simplifié)

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つの条件。grass/dirt/stone/deepslate/bedrockのレイヤー。表面ブロックはbiome_top[]でバイオームに依存 -- 平原はgrass、砂漠はsand。ループなし、switchなし、該当するレイヤーに落ちるifのカスケード。

洞窟、最も怠惰なミラーリング

altitude_grotte = CAVE_BASE_DEPTH - (hauteur_surface - y);

地表の高さを地下でミラーリングする。deepslateの大きな空洞みたいになる。計算量ゼロ、たった1行。

Caves générées par mirror du terrain de surface

Schéma du mirror de terrain pour générer les grottes

鉱石、XOR版

candidat = (chunk_x ^ col_x ^ col_z) % 100;
if (candidat < 5 && y < 16) -> diamond

座標のXORで列ごとに候補が一意に決まる。タイプは高度だけで決まる。ダイアモンドは洞窟の最深部の下に隠れてて、掘りごたえを残してる。

タイルマップ式バイオーム

各バイオームはグリッド内の円形の島で、タイプはシードから計算されたパターンで決まる。グリッド化されてて、予測可能で、タダ。

Carte des biomes en tile map -- chaque île est un biome différent

各バイオームには独自のパラメータセットがあり、配列にエンコードされてる:

// worldgen.c (simplifié)

static const uint8_t biome_base[] = {
  [BIOME_PLAINS]  = 48,   // 基本高さ:48
  [BIOME_DESERT]  = 52,   // やや高め
  [BIOME_FOREST]  = 50,   // 中間
  [BIOME_TAIGA]   = 46,   // やや低め
  [BIOME_SNOWY]   = 40,   // 最も低い
};

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抽出 → 非常に規則的
  [BIOME_DESERT]  = 3,   // 3抽出 → 中程度
  [BIOME_FOREST]  = 4,   // 4抽出 → 規則的、丘陵あり
  [BIOME_TAIGA]   = 3,   // 3抽出 → 中程度
  [BIOME_SNOWY]   = 2,   // 2抽出 → 非常に起伏大
};

平原:高さ48、4要素 → 非常に平坦な地形、草。

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// 結果:最大±4ブロックの変動

砂漠:高さ52、3要素、表面ブロック = 砂。海面より下にならない。

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// 結果:最大±6ブロックの変動、SEA_LEVEL+1でクランプ

森林:高さ50、4要素(平原と同じだがベースが高い)→ 樹木の生い茂った丘陵。

タイガ:高さ46、3要素 → 中程度の変動、寒冷地形。

氷雪平原:高さ40、わずか2要素 → 最も起伏が激しい。

h = (hash % 5) + ((hash >> 4) % 5);
// 結果:最大±14ブロックの変動

各バイオームは5エントリ×3配列でエンコードされてる:基本高さ、表面ブロック、要素数。getHeightAtFromHashがバイオームを受け取ると、これらの配列を参照して地形を調整する。たった15バイトのデータでMinecraftのバイオームシステム全体を置き換えてる。

バイオーム検出器はシードを使って各チャンクにどのバイオームが対応するかを決める:

// worldgen.c (simplifié)

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];
}

16エントリのパターン、チャンク座標でシードされたインデックス。繰り返しのグリッドになるが視覚的に一貫性がある。たった4行のコードでバニラMinecraftのバイオームパラメータシステム全体を置き換えてる。

getHeightAtFromHash : 地形の組み立て屋

生成の中核となる関数は、バイオームでシードされた4つの隅を組み合わせる:

// worldgen.c (simplifié)

static uint8_t getHeightAtFromHash (int rx, int rz, short cx, short cz,
                                    uint32_t h, uint8_t biome) {
  // ハッシュから抽出した4隅、隅ごとに異なるシード
  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);

  // バイオーム制約:砂漠は水面下にならない
  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);
  }

  // 4隅からの補間
  return interpolate(h1, h2, h3, h4, rx, rz);
}

各バイオームには基準高さをシフトするbiome_baseがあり、4つの隅は異なるシフト量でハッシュから抽出される。砂漠は海面以上の最小値を強制 -- 追加のバイオーム計算なしで水を回避する1行の制約。

木とサボテン:確率的配置

地表生成は同じチャンクハッシュを使ってどこに何を置くかを決める:

// worldgen.c (simplifié)

static void genFoliage (uint8_t *chunk_data, short cx, short cz,
                        uint32_t hash, uint8_t biome) {
  if (biome == BIOME_DESERT) {
    // サボテン:チャンクあたり1候補、ハッシュが位置を決める
    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 {
    // 木:ハッシュが配置するかどうかと場所を決める
    int tree_count = (hash & 3);  // チャンクあたり0-3本
    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本の木、砂漠では最大1つのサボテン。チャンクハッシュが唯一のエントロピー源 -- チャンク内の位置は& 7、カウンターは& 3。すべて決定論的で、何も保存されない。

generateChunk : 全部まとめる

8×8×256ブロックの完全なチャンクを生成する関数:

// worldgen.c (simplifié)

void generateChunk (uint8_t *chunk, short cx, short cz) {
  uint32_t hash = getChunkHash(cx, cz);
  uint8_t biome = getChunkBiome(cx, cz);

  // チャンクの各列に対して(8×8 = 64)
  for (int x = 0; x < 8; x ++) {
    for (int z = 0; z < 8; z ++) {
      // 絶対ワールド座標
      int wx = cx * 8 + x;
      int wz = cz * 8 + z;

      // 列の高さ
      uint8_t height = getHeightAt(wx, wz);

      // 下から上に列を埋める
      for (int y = 0; y < height; y ++) {
        uint8_t block = getTerrainBlock(wx, y, wz);
        chunk[y * 64 + z * 8 + x] = block;
      }
    }
  }

  // 地表要素の追加(木、サボテン)
  genFoliage(chunk, cx, cz, hash, biome);
}

これだけ。3重ループ:各列について、高さを求め、ブロックを埋め、次へ。出力はuint8_t[16384](8×8×256)で完全なチャンクを表す。キャッシュなし、レイジーローディングなし、圧縮なし -- チャンクは生成されて直接クライアントに送られる。

ストレージ:全部静的配列

Bareironのメモリアーキテクチャは、組み込みCの真骨頂。mallocなし、ハッシュマップなし、リンクリストなし。

全部が固定サイズのグローバル配列に入ってる。

ブロックの変更

// globals.h, lignes 191-196

typedef struct {
  short x;      // 2バイト -- 水平方向32000ブロックが上限
  short z;      // 2バイト
  uint8_t y;    // 1バイト -- 垂直方向256ブロックが上限
  uint8_t block; // 1バイト -- 256ブロックタイプが上限
} BlockChange;

20,000エントリ、つまり約25,000回の変更 -- チャンク1.5個分を完全に掘り返した量に相当。blockフィールドが0xFFのときは空きエントリを示す。検索は線形スキャン:

Layout mémoire du tableau de blocs -- 6 bytes par entrée

// 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;
}

変更の追加も検索と同じくらい直接的:

```c
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 ++;
}

カウンタ、インデックス、書き込み。ソートなし、コンパクションなし、メモリ管理なし。配列がいっぱいになると新しい変更は無視される -- 地形は生成時の状態に戻る。

256ブロック上限に関する作者のコメント:「ちょっとくすんだ銅の階段とか、そういうのはまだ実装する気ないんだよね」。

モブ:1匹8バイト

// globals.h, lignes 240-251 (pragma pack(push, 1) でパディングを削除)

typedef struct {
  uint8_t type;   // 25=ニワトリ, 28=ウシ, 95=ブタ, 106=ヒツジ, 145=ゾンビ
  short x;
  uint8_t y;      // health=0の場合、Yは削除前のタイマーになる
  short z;
  uint8_t data;   // ビット0-4: HP, ビット5: ヒツジの刈り取り, ビット6-7: パニックタイマー
} MobData;

8バイト。最大16スロット。アラインメントなし、パディングなし。dataバイトは自作ビットフィールド:5ビットのHP、1ビットの刈り取り、2ビットのパニックタイマー。そしてモブが死ぬと、Yフィールドが削除前のタイマーになる。ビットレベルでメモリを再利用してる。

プレイヤー:ギュウギュウ詰め

プレイヤーデータも#pragma pack(push, 1)を使用 -- 座標はshort + uint8_t、インベントリは固定配列のuint16_t + uint8_t、そしてflagsフィールドには攻撃クールダウン、スポーン状態、スニーク、スプリント、食事、ロード、移動クールダウン、クラフトロックのすべてがエンコードされてる。全部個別のビットに詰め込まれてる。

メインループ:while(true)とノンブロッキング

サーバー全体が1つのループ、1つのスレッド、イベントライブラリゼロで動く。

// main.c, lignes 594-720

while (true) {
  task_yield();  // ESP32のウォッチドッグを落ち着かせる

  // 新しい接続を受け付ける(ノンブロッキング)
  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;
  }

  // 時間が経過していたらサーバーティック
  if (get_program_time() - last_tick_time > TIME_BETWEEN_TICKS) {
    handleServerTick(time_since_last_tick);
    last_tick_time = get_program_time();
  }

  // ラウンドロビン:1クライアント、1イテレーションにつき1パケット
  client_index = (client_index + 1) % MAX_PLAYERS;
  if (clients[client_index] == -1) continue;

  // パケットヘッダを読む: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);
}

ループ1イテレーションにつき1クライアントだけが処理され、1パケットだけが読まれる。ループ先頭のtask_yield()でESP32上のFreeRTOSアイドルタスクが息継ぎできる -- これがないとウォッチドッグタイマーがチップをリセットする。

パケットディスパッチは400行の巨大なswitch文:

// main.c, lignes 68-497

void handlePacket (int client_fd, int length, int packet_id, int state) {
  switch (packet_id) {
    case 0x00:  // 状態に応じてHandshake / Status / Login
    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 (チェストを処理)
    case 0x19:  // Interact entity
    case 0x1D..0x20:  // Movement packets (最大のケース)
    case 0x28:  // Player action (掘る/置く)
    // ... 40以上のケース
  }
}

動的ジャンプテーブルなし、vtableなし、マップなし。switchは静的ジャンプテーブルにコンパイルされる。組み込みに最適。

0x1D-0x20のケースが最大 -- 位置更新、落下ダメージ、チャンク境界通過、モブスポーン、チャンク生成、そして空腹までもを処理する。全部1つの巨大なフォールスルーで。

Le code du serveur Bareiron -- 6800 lignes de C

サーバーティックとモブAI

handleServerTick関数は50msごと(20 TPS)に呼び出される。メインループがプレイヤーを処理している間に、ワールドを管理する:

// main.c (simplifié)

void handleServerTick (uint32_t delta) {
  // 各モブを更新
  for (int i = 0; i < MOB_COUNT; i ++) {
    if (mobs[i].type == 0 || mobs[i].data == 0) continue;  // 死亡または空

    MobData *mob = &mobs[i];
    int px, pz;
    getNearestPlayer(mob->x, mob->z, &px, &pz);

    if (mob->type == MOB_ZOMBIE) {
      // 敵対的:最も近いプレイヤーに向かって歩く
      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 ++;
      // 2ブロック以内で接触ダメージ
      if (abs(px - mob->x) <= 2 && abs(pz - mob->z) <= 2)
        damagePlayer(getNearestPlayerId(mob->x, mob->z), 3);
    } else {
      // 受動的:8方向ランダム
      uint8_t dir = getMobDir(mob);
      mob->x += dir_lookup[dir][0];
      mob->z += dir_lookup[dir][1];
      // 約40ティックごとに方向転換
      if (mob->data >> 6 < 1) setMobDir(mob, rand() & 7);
      mob->data = (mob->data & 0x3F) | ((mob->data - 0x40) & 0xC0);
    }

    // モブ周辺のチャンクを起動
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

敵対モブのAIは座標比較。文字通り if (px < x) x--。パスファインディングなし、A*なし、障害物回避なし。ゾンビはプレイヤーに向かってXとZを独立に調整する -- 壁があれば壁の中も突き進む。

接触ダメージはハート3個/秒。p2r3は意図的に高く設定している。パスファインディングがない分、ゾンビは簡単にキットできるからだ。

防具の計算式はコンバットアップデート前のもの -- 可能な限りシンプル:

// main.c (simplifié)

static uint8_t applyArmor (uint8_t damage, uint16_t armor_value) {
  // 1.9以前の計算式:線形減少
  // 防具ポイント1につき4%軽減、最大80%
  uint8_t reduction = (armor_value * 4);
  if (reduction > 80) reduction = 80;
  return damage * (100 - reduction) / 100;
}

フルダイアモンド = 80%軽減。ゾンビの3ハート攻撃が0.6ハートになる。p2r3がこの古い計算式を選んだのは、2回の演算で済むからだ -- しきい値なし、曲線なし、ただの線形パーセンテージ。

受動的モブ:ルックアップテーブルで8方向、約40ティックごとに進路変更。dataフィールドは上位2ビットに現在の方向、残り6ビットに方向転換タイマーをエンコードしてる。

Mobs dans Bareiron -- zombies, cochons, moutons

モブのリスポーン

モブはランダムティックでスポーンしない。サーバーティックが新しいチャンク境界に遭遇したときに出現する:

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;
  }
}

地形と同じRNG、同じチャンクシード。モブスロットが空いていれば、スポーンは決定論的。

クラフト:マトリックスなし、if/elseのみ

// crafting.c, lignes 9-347 (simplifié)

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // 0x80フラグが立っている場合、クラフトバッファはチェストに使用中
  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:  /* 板材、インゴット... */
    case 2:  /* 棒、ハサミ、松明 */
    case 3:  /* シャベル、剣、ハーフブロック */
    case 4:  /* 作業台、ブーツ */
    case 5:  /* ツルハシ、斧、ヘルメット */
    case 7:  /* レギンス、コンポスター */
    case 8:  /* かまど、チェスト、チェストプレート */
    case 9:  /* 完全ブロック(鉄、金など) */
  }
}

最初のチェック:0x80フラグが立っている場合、クラフトバッファはチェストポインタとして再利用されている。クラフト不可能。

次に、埋まったスロットを数え、最初のアイテムを記録し、同一性を確認する。これだけで、かまどは4つのチェックでマッチする:

if (count == 8 && first == cobblestone && all_identical && center_empty)
    return furnace;

複雑な形状の場合、最初のアイテムのインデックスを使って相対位置をチェックする。すべてのレシピは同じマッチング関数を共有し、素材が結果を決定する。

Interface de craft et coffre dans Bareiron

チェスト:本当のハック

みんなが話題にしてるメモリハック、実際のコード:

// procedures.c, lignes 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;

  // 恐ろしいメモリハック!!
  // ポインタをプレイヤーのクラフトアイテム配列にコピー
  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);
  }
}

そしてコード内のコメント:// 恐ろしいメモリハック!!1!

まさにその通り。block_changes[]の次のエントリのメモリアドレスを取得し、それをplayer->craft_items(uint16_t[9]、つまり18バイト -- 32ビットポインタを保存するのに十分)にコピーし、その間誰もクラフトしようとしないようにフラグを立てる。

チェストインベントリ内での各クリック:

// packets.c, lignes 620-638

uint8_t *storage_ptr;
memcpy(&storage_ptr, player->craft_items, sizeof(storage_ptr));
// storage_ptrは今チェストデータを指している
uint16_t *p_item = (uint16_t *)(storage_ptr + (slot - 41) * 3);
uint8_t *p_count = storage_ptr + (slot - 41) * 3 + 2;

クラフトバッファからポインタを復元し、オフセットでスロットにアクセスする。チェストデータは1スロットあたり3バイト(IDに2、数量に1)で、ブロック配列内に連続して格納されている。

Données de coffre stockées dans le tableau de blocs -- un hack mémoire

空腹:天才の5行

// main.c, lignes 293-305

// プレイヤーは移動中は毎秒約20個の移動パケットを送信し、
// 停止中ははるかに少ない。これをアクティビティと相関させて
// 無料で空腹をシミュレートする。
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) {  // スプリント中
  player->saturation -= 1;
}

まさにこれだけ。5行。各移動パケットが満腹度を減らす。満腹度がゼロになると空腹度が減り、満腹度がリセットされる。スプリント(0x08フラグ)は消費を2倍にする。

タイマーゼロ、割り当てメモリゼロ、専用計算ゼロ。すでに存在するパケットで減算されるカウンタ。

落下ダメージ

このプロジェクトで最もシンプルなダメージシステム:

// プレイヤーが地面を離れた時点のYを保存
// 再着地したときの差を計算
ダメージ = 最後の接地Y - 現在Y

引き算1つ。

ブロックの採掘と設置

ブロックをクリックすると、パケット0x28(Player Action)がswitchに届く。ハンドラはその位置にあるブロックを特定し、削除し、アイテムをインベントリに入れる必要がある:

// main.c, case 0x28 (simplifié)

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;
      }

      // block_changesに追加
      addBlockChange(x, z, y, 0);  // 0 = 空気

      // プレイヤーにアイテムを付与(クライアントを信頼)
      addItemToPlayer(client_fd, block_to_item(block), 1);

      // クライアントに更新を送信
      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は地形生成とプレイヤーの変更を組み合わせる:

uint8_t getBlockAt (int x, uint8_t y, int z) {
  // まずプレイヤーの変更を確認
  uint8_t change = getBlockChange(x, y, z);
  if (change != 0xFF) return change;

  // なければ生成された地形から読む
  return getTerrainBlock(x, y, z);
}

変更を優先、フォールバックは地形。議論の余地なし、キャッシュなし、オーバーヘッドなし。getTerrainBlockの内部はgetHeightAt + stone/dirt/grass/coalのレイヤー。

瞬間沸騰炉

一番面白いところ:かまどはエンティティとして存在しない。「調理」スロットに丸石を、「燃料」に石炭を入れると、結果が即座に表示される。タイマーなし、チャンクティッキングなし。適切なアイテムを入れると空になるただのインベントリスロットだ。

Fourneau instantané -- pose les ingrédients, résultat immédiat

ESP32ループ:4KBのスタックで動くMCサーバー

// main.c, lignes 732-779

#ifdef ESP_PLATFORM

void bareiron_main (void *pvParameters) {
  main();
  vTaskDelete(NULL);
}

static void wifi_event_handler (...) {
  if (/* 接続完了 */) {
    xTaskCreate(bareiron_main, "bareiron", 4096, NULL, 5, NULL);
  }
}

void app_main () {
  esp_timer_early_init();
  wifi_init();
  // 残りはイベントハンドラが処理
}
#endif

サーバー全体が4096バイトのスタックを持つFreeRTOSタスクで動く。それだけ。メインのスレッドはWiFiを初期化して接続を待つだけ。接続されると、標準のmain()を呼び出すbareiron_mainを起動する。

ESP32固有のコードはすべて#ifdef ESP_PLATFORMで保護されている。PC上では、すべて標準POSIXコードとしてコンパイルされる。

犠牲にされたもの

これをすべて収めるために、存在しないバニラ機能がある:

  • ネットワーク圧縮なし -- zlibが高コストすぎる。サーバーはチャンクを高速生成するが、送信がボトルネックになる。
  • ランダムティックなし -- 木は骨粉を使うか使わないかで成長する。モブはチャンク境界でスポーンする。
  • アイテムエンティティなし -- 採掘したブロックは直接インベントリに入る。アニメーションは純粋に視覚的。
  • インベントリ検証なし -- クライアントを信頼。ダイアモンド64個?OK。1秒でチャンクを掘り尽くす?OK。信頼できる人同士で使う用。
  • サーバー側ライティングなし -- 松明は他のものより後に送信され、クライアントが計算する。
  • 進行性流体なし -- 最終状態が即座に反映される。

最終結果

Ryzen 5 3600:チャンクあたり約0.5 ms。 1ドルのESP32-C3:チャンクあたり約200 ms。プレイ可能。

Benchmark de génération de chunks -- Ryzen vs ESP32

3人以上のプレイヤー:重くなる。作者曰く、ピーク時の2b2t並み。

Plusieurs joueurs connectés au même serveur Bareiron

哲学

p2r3:「たった1ドルで0.5ワットしか消費しないこの小さなチップが、Minecraftほど先進的なものを動かせるというアイデアがただ好きなんだ。科学は『なぜ』じゃない、『なぜやらない』なんだよ」。

すべての行はトレードオフだ:

  • パーリンノイズ → 補間:見た目は劣るが、200倍高速、メモリゼロ
  • クラフトマトリックス → ハードコードされたマッチング:コードは汚いが、バイト数ゼロ
  • zlib → なし:回線が悪ければ死ぬが、プレイ可能
  • 検証 → 信頼:セキュリティゼロ、計算量ゼロ

欠けている機能のひとつひとつが、別の機能をハードウェアの制限内で存在させるためにある。

覚えておくべき3つのポイント:

  1. 補間 + RNG -- シードされた4点、無限の地形、保存量ゼロ、チャンク再生成不要のクエリ、200 msの生成。これがすべてを可能にした天才的な一手だ。
  2. すべての機能にはコストが伴う -- 圧縮なし、ランダムティックなし、検証なし。これは忘れたんじゃない。520 KBに収めるために必要なことだ。
  3. 汚いハックが一番賢い -- memcpyによるブロック配列内のチェスト、移動パケットによる空腹、瞬間沸騰炉。きれいな解決策は高すぎた。

このプロジェクトに興味があれば、全部 GitHubでGPLv3 で公開されてる。めちゃくちゃ汚いC言語だ。そしてこんなに楽しくソースコードを読めたことは滅多にない xD

Bareiron -- 1$ 마이크로컨트롤러에서 돌아가는 마인크래프트 서버

C 언어 6800줄, malloc 제로, Perlin noise 대신 bilinear interpolation,

서론

혹시 1$ 마이크로컨트롤러에서 마인크래프트 서버를 돌릴 수 있을지 궁금해한 적 있어?

나는 궁금했어. 그리고 답은 "된다"야. 진짜로.

p2r3가 만든 Bareiron이라는 프로젝트가 있는데, 아마 최근 몇 년간 마인크래프트 세계에서 내가 본 것 중 가장 매혹적인 프로젝트 중 하나일 거야. 300킬로바이트 바이너리, C 언어 6800줄, 외부 의존성 제로, malloc 없음, 스레딩 없음, 그리고 1달러 ESP32 위에서 동작해.

ESP32-C3, 서버를 구동하는 마이크로컨트롤러

무한 지형 생성. 바이옴. 동굴. 제작. 채광. 몹. 배고픔. 상자. 서바이벌 서버에서 기대하는 모든 것.

0.5와트 전력 소모에 160MHz 클럭인 칩 위에서 말이야.

비교를 해보자면: 바닐라 마인크래프트 서버는 수 기가바이트의 RAM이 필요해. ESP32-C3는 520KB SRAM (부팅 후 사용 가능 400KB)이 전부야. 20년 전 프로세서들도 이미 기가헤르츠로 돌아갔었지 -- 이건 160MHz가 한계야. 순수 성능 차이는 약 20,000배나 돼.

p2r3는 그냥 마인크래프트 서버를 C로 포팅한 게 아니야. 이 제약 안에 들어맞도록 서버의 모든 구성 요소를 처음부터 다시 발명했어. 소스 코드를 뜯어보면서 어떻게 했는지 살펴보자.

p2r3의 Bareiron 소개 영상 썸네일

프로젝트의 핵심: 메모리 없는 지형 생성

내장형 MC 서버를 만들 때 가장 큰 문제는 지형 생성이야.

바닐라 마인크래프트에서 세계는 Perlin noise로 생성돼: 여러 겹의 옥타브를 겹치고, 6개의 바이옴 파라미터(온도, 습기, 대륙성, 침식, 기이함, 깊이)를 사용하며, 매번 전부 재계산하지 않도록 캐싱 시스템도 갖추고 있어.

결과물은 장관이야. 하지만 계산 비용이 크고, 생성된 청크를 저장할 RAM도 많이 필요해.

Bareiron의 접근 방식은 근본적으로 달라. 노이즈를 쌓는 대신, 결정론적 RNG로 생성된 4개 점의 bilinear interpolation을 사용해.

작고 픽셀화된 이미지를 확대할 때 가장자리가 흐릿해지는 거 알지? 바로 그거야.

// worldgen.c, 라인 117-171 (단순화)

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);
  // hash + biome으로 시드된 4개 코너 보간
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

표준 쌍선형 보간: 4개 코너, 위치에 따른 가중치, 출력은 단일 uint8_t. CHUNK_SIZE는 8이니까 정수 곱셈으로 처리 가능, float 없음.

p2r3가 영상에서 단계별로 보여줘: 먼저 청크의 4개 코너, 각각 RNG로 시드된 높이를 가져.

청크의 4개 코너, 각각 결정론적 RNG로 시드됨

그 다음 4개 점 사이의 보간이 연속적인 표면을 만들어내.

4개 코너 사이에 bilinear interpolation 적용

이 패턴을 모든 인접 청크에 반복하면 무한히 뻗어나가는 지형을 얻을 수 있어.

최종 결과: 연속적인 불규칙 지형

결정론적 RNG

이 모든 걸 가능하게 하는 핵심은 시딩이야. 각 청크는 4개의 코너를 갖고, 각 코너는 고유하지만 재현 가능한 의사 난수 값이 필요해.

// worldgen.c, 라인 13-22

uint32_t getChunkHash (short x, short z) {
  uint8_t buf[8];
  memcpy(buf, &x, 2);          // X 좌표 16비트
  memcpy(buf + 2, &z, 2);      // Z 좌표 16비트
  memcpy(buf + 4, &world_seed, 4);  // 전역 시드 32비트
  return splitmix64(*((uint64_t *)buf));  // 해시
}

X의 16비트, Z의 16비트, 시드의 32비트를 8바이트 버퍼에 패킹하고 splitmix64에 통과시켜. 결과: 월드 시드를 기반으로 각 위치에 대한 결정론적 고유 값.

이게 얼마나 강력한지 알아? 서버가 지형을 저장할 필요가 없어. 플레이어가 새 영역에 도착하면 실시간으로 재계산하고, 매번 정확히 같은 결과를 내놓는 거야.

사용된 splitmix64는 64비트 해시용으로 설계된 초고속 PRNG야:

// worldgen.c (단순화)

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개 연산: 덧셈, xor/시프트, 곱셈, xor/시프트, 곱셈, xor/시프트. 룩업 테이블 없음, 루프 없음. 8바이트 버퍼(X + Z + 시드)를 받아서 64비트 정수로 처리하고, 32비트 해시를 반환해. 결정론적이고 빠르며 5줄이면 끝나.

왜 Perlin noise가 아닌가

p2r3가 영상에서 직접 말했어: "난수에서 더 많은 자릿수를 사용할수록 지형이 더 규칙적이게 돼, 마치 동전 던지기를 많이 할수록 50/50에 가까워지는 것처럼." 실제로는, 그가 결합하는 해시 비트 수에 따라 달라져:

// worldgen.c, 라인 51-115

// 평원 바이옴: 4개 요소 결합 → 평탄한 지형
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// 설원: 2개 요소 → 더 울퉁불퉁
h = (hash % 5) + ((hash >> 4) % 5);

각 바이옴은 몇 개의 비트 추출을 결합할지 선택해. 많을수록 분포가 안정화되지 -- 동전 던지기를 많이 할수록 50/50에 가까워지는 것처럼. 적을수록 지역 변동이 더 커져.

불규칙 지형 -- 적은 요소, 강한 변동

2개 요소만 사용하면, 설원은 구릉지에 가깝고 거의 산지 같은 지형을 만들어. 봉우리와 골짜기가 빈번해.

규칙적인 지형 -- 여러 요소, 부드러운 표면

4개 요소를 사용하면, 평원은 평탄하고 예측 가능하게 유지돼. 분포가 안정화되지.

청크 하나 생성에 ESP32에서 200ms 걸려 -- 같은 하드웨어에서 Perlin noise는 너무 무거워서 측정조차 안 될 정도야.

결정적인 디테일: 전체 청크 생성 없이 블록 조회하기

플레이어가 블록을 캐. 서버는 어떤 아이템을 줄지 알아내야 해. 단순하게는, 이를 위해 청크 전체를 생성해야 할 거야.

쌍선형 보간을 사용하면, 좌표만으로 평면의 어느 점이든 직접 조회할 수 있어. 청크의 코너는 플레이어 위치에서 얻고, 보간이 임의의 오프셋에서 높이를 알려줘. 소수의 수학 연산만으로, 청크 생성은 필요 없어.

p2r3: "내가 원하는 건, 메모리에 접근하거나 비싼 노이즈 맵을 계산하지 않고도 주어진 좌표에 어떤 블록이 있는지 알려주는 마법 같은 함수야." 정확히 그가 만든 거지.

높이가 어떻게 구체적인 블록이 되는지 보자:

// worldgen.c (단순화)

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개 조건. grass/dirt/stone/deepslate/bedrock 레이어. 표면 블록은 biome_top[]을 통해 바이옴에 따라 달라져 -- 평원은 grass, 사막은 sand. 루프 없음, switch 없음, 올바른 레이어로 떨어지는 if 연속.

동굴, 가장 게으른 미러링

동굴_고도 = CAVE_BASE_DEPTH - (표면_높이 - y);

표면 높이를 지하로 미러링해. 깊은 슬레이트의 큰 공동처럼 보여. 계산 제로, 한 줄이면 끝.

표면 지형 미러링으로 생성된 동굴

동굴 생성을 위한 지형 미러링 다이어그램

광물, XOR 버전

후보 = (chunk_x ^ col_x ^ col_z) % 100;
if (후보 < 5 && y < 16) -> 다이아몬드

좌표 XOR은 열 당 하나의 후보를 보장해. 유형은 고도에만 의존해. 다이아몬드는 동굴의 가장 낮은 지점 아래 숨겨져 있어서 채굴이 의미 있게 해.

타일 맵 바이옴

각 바이옴은 그리드 안의 원형 섬이고, 그 유형은 시드에서 계산된 패턴에 의해 결정돼. 격자 형태, 예측 가능, 공짜야.

타일 맵 바이옴 지도 -- 각 섬이 다른 바이옴

각 바이옴은 배열에 인코딩된 고유한 파라미터 세트를 가져:

// worldgen.c (단순화)

static const uint8_t biome_base[] = {
  [BIOME_PLAINS]  = 48,   // 기본 높이: 48
  [BIOME_DESERT]  = 52,   // 약간 더 높음
  [BIOME_FOREST]  = 50,   // 중간
  [BIOME_TAIGA]   = 46,   // 약간 낮음
  [BIOME_SNOWY]   = 40,   // 가장 낮음
};

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개 추출 → 매우 규칙적
  [BIOME_DESERT]  = 3,   // 3개 추출 → 보통
  [BIOME_FOREST]  = 4,   // 4개 추출 → 규칙적, 구릉
  [BIOME_TAIGA]   = 3,   // 3개 추출 → 보통
  [BIOME_SNOWY]   = 2,   // 2개 추출 → 매우 울퉁불퉁
};

평원(Plains): 높이 48, 4개 요소 → 매우 평탄한 지형, 잔디.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// 결과: 최대 ±4 블록 변동

사막(Desert): 높이 52, 3개 요소, 표면 블록 = 모래. 절대 해수면 아래로 내려가지 않음.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// 결과: 최대 ±6 블록 변동, SEA_LEVEL+1으로 클램프

숲(Forest): 높이 50, 4개 요소, 평원과 같지만 기준이 더 높음 → 숲이 우거진 언덕.

타이가(Taiga): 높이 46, 3개 요소 → 적당한 변동, 차가운 지형.

설원(Snowy plains): 높이 40, 단 2개 요소 → 가장 울퉁불퉁.

h = (hash % 5) + ((hash >> 4) % 5);
// 결과: 최대 ±14 블록 변동

각 바이옴은 5개 항목의 3개 배열에 인코딩돼: 기본 높이, 표면 블록, 요소 수. getHeightAtFromHash가 바이옴을 받으면 이 배열들을 참조해 지형을 조정해. 마인크래프트의 전체 바이옴 시스템을 대체하는 15바이트 데이터.

바이옴 감지기는 시드를 사용해 각 청크에 어떤 바이옴이 매핑되는지 결정해:

// worldgen.c (단순화)

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];
}

16개 항목 패턴, 청크 좌표로 시드된 인덱스. 반복적이지만 시각적으로 일관성 있는 그리드를 만들어. 마인크래프트 바닐라의 전체 바이옴 파라미터 시스템을 대체하는 4줄의 코드.

getHeightAtFromHash: 지형 조립기

생성의 핵심 함수는 바이옴으로 시드된 4개 코너를 결합해:

// worldgen.c (단순화)

static uint8_t getHeightAtFromHash (int rx, int rz, short cx, short cz,
                                    uint32_t h, uint8_t biome) {
  // 해시에서 추출한 4개 코너, 코너마다 다른 시드
  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);

  // 바이옴 제약: 사막은 절대 물에 잠기지 않음
  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);
  }

  // 4개 코너에서 보간
  return interpolate(h1, h2, h3, h4, rx, rz);
}

각 바이옴은 기준 높이를 이동시키는 biome_base를 가지고, 4개 코너는 해시에서 다른 시프트로 추출돼. 사막은 해수면 이상으로 최소값을 강제하는데 -- 추가 바이옴 계산 없이 물을 피하는 한 줄의 제약이야.

나무와 선인장: 확률적 배치

표면 생성은 동일한 청크 해시를 사용해 어디에 심을지 결정해:

// worldgen.c (단순화)

static void genFoliage (uint8_t *chunk_data, short cx, short cz,
                        uint32_t hash, uint8_t biome) {
  if (biome == BIOME_DESERT) {
    // 선인장: 청크 당 한 개, 해시가 위치 결정
    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 {
    // 나무: 해시가 배치 여부와 위치 결정
    int tree_count = (hash & 3);  // 청크 당 0-3 그루
    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그루의 나무, 사막은 최대 1개의 선인장. 청크 해시가 유일한 엔트로피 소스야 -- & 7로 청크 내 위치, & 3로 카운터. 모든 것이 결정론적이고, 아무것도 저장되지 않아.

generateChunk: 모든 것 조립하기

8×8×256 블록의 완전한 청크를 생성하는 함수:

// worldgen.c (단순화)

void generateChunk (uint8_t *chunk, short cx, short cz) {
  uint32_t hash = getChunkHash(cx, cz);
  uint8_t biome = getChunkBiome(cx, cz);

  // 청크의 각 열에 대해 (8×8 = 64)
  for (int x = 0; x < 8; x ++) {
    for (int z = 0; z < 8; z ++) {
      // 절대 월드 좌표
      int wx = cx * 8 + x;
      int wz = cz * 8 + z;

      // 열 높이
      uint8_t height = getHeightAt(wx, wz);

      // 아래에서 위로 열 채우기
      for (int y = 0; y < height; y ++) {
        uint8_t block = getTerrainBlock(wx, y, wz);
        chunk[y * 64 + z * 8 + x] = block;
      }
    }
  }

  // 표면 요소 추가 (나무, 선인장)
  genFoliage(chunk, cx, cz, hash, biome);
}

이게 전부야. 3개 중첩 루프: 각 열마다 높이를 찾고, 블록을 채우고, 다음으로 넘어가. 출력은 완전한 청크를 나타내는 uint8_t[16384] (8 × 8 × 256)야. 캐싱 없음, 레이지 로딩 없음, 압축 없음 -- 청크가 생성되는 즉시 클라이언트로 전송돼.

저장소: 모든 것이 정적 배열

Bareiron의 메모리 아키텍처는 내장형 C의 진수를 보여줘. malloc 없음, 해시 맵 없음, 연결 리스트 없음.

모든 것이 고정 크기의 전역 배열 안에 있어.

블록 변경

// globals.h, 라인 191-196

typedef struct {
  short x;      // 2바이트 -- 수평 32,000 블록 제한
  short z;      // 2바이트
  uint8_t y;    // 1바이트 -- 수직 256 블록 제한
  uint8_t block; // 1바이트 -- 256 블록 타입 제한
} BlockChange;

20,000개 항목, 약 25,000개 변경 -- 1.5 청크를 완전히 파낸 것과 맞먹어. block 필드가 0xFF면 빈 항목을 표시해. 검색은 선형 스캔:

블록 배열 메모리 레이아웃 -- 항목 당 6바이트

// 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;
}

변경 추가도 검색만큼 직접적이야:

```c
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 ++;
}

카운터, 인덱스, 쓰기. 정렬 없음, 압축 없음, 메모리 관리 없음. 배열이 가득 차면 새 변경은 무시돼 -- 지형이 생성된 상태로 되돌아가.

256 블록 제한에 대한 작성자의 코멘트: "가볍게 산화된 구리 계단은 당장 구현할 생각 없음."

몹: 머리 당 8바이트

// globals.h, 라인 240-251 (패딩 제거를 위한 pragma pack(push, 1))

typedef struct {
  uint8_t type;   // 25=닭, 28=소, 95=돼지, 106=양, 145=좀비
  short x;
  uint8_t y;      // health=0이면 Y가 제거 전 타이머가 됨
  short z;
  uint8_t data;   // 비트 0-4: 체력, 비트 5: 양 털깎기, 비트 6-7: 공포 타이머
} MobData;

8바이트. 최대 16개 슬롯. 정렬 없음, 패딩 없음. data 바이트는 수제 비트필드야: 체력 5비트, 털깎기 1비트, 공포 타이머 2비트. 그리고 몹이 죽으면, Y 필드가 제거 전 타이머가 돼. 비트 수준의 메모리 재활용이지.

플레이어: 빈틈없이 패킹

플레이어 데이터도 #pragma pack(push, 1)을 사용해 -- 좌표는 short + uint8_t, 인벤토리는 uint16_t + uint8_t 고정 배열, 그리고 flags 필드는 공격 쿨다운, 스폰 상태, 스니킹, 스프린팅, 식사, 로딩, 이동 쿨다운, 제작 잠금을 전부 인코딩해. 이 모든 게 개별 비트에 들어있어.

메인 루프: while(true)와 논블로킹

서버 전체가 단일 루프, 단일 스레드, 이벤트 라이브러리 제로로 돌아가.

// main.c, 라인 594-720

while (true) {
  task_yield();  // ESP32에서 watchdog 숨 쉬게 하기

  // 새 연결 수락 (논블로킹)
  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;
  }

  // 시간이 지났으면 서버 틱
  if (get_program_time() - last_tick_time > TIME_BETWEEN_TICKS) {
    handleServerTick(time_since_last_tick);
    last_tick_time = get_program_time();
  }

  // 라운드 로빈: 한 클라이언트, 반복 당 한 패킷
  client_index = (client_index + 1) % MAX_PLAYERS;
  if (clients[client_index] == -1) continue;

  // 패킷 헤더 읽기: 길이 + 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);
}

루프 반복 당 하나의 클라이언트만 처리되고, 한 번에 하나의 패킷만 읽혀. 루프 시작 부분의 task_yield()는 ESP32에서 FreeRTOS idle 태스크가 숨 쉴 수 있게 해 -- 이게 없으면 watchdog 타이머가 칩을 리셋시켜 버려.

패킷 디스패치는 400줄짜리 거대한 switch야:

// main.c, 라인 68-497

void handlePacket (int client_fd, int length, int packet_id, int state) {
  switch (packet_id) {
    case 0x00:  // Handshake / Status / Login (상태에 따라)
    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 (상자 처리)
    case 0x19:  // Interact entity
    case 0x1D..0x20:  // Movement packets (가장 큰 케이스)
    case 0x28:  // Player action (dig/place)
    // ... 40개 이상 케이스
  }
}

동적 점프 테이블 없음, vtable 없음, 맵 없음. switch는 정적 점프 테이블로 컴파일돼. 내장형에 완벽하지.

0x1D-0x20 케이스가 가장 커 -- 위치 업데이트, 낙하 데미지, 청크 경계 횡단, 몹 스폰, 청크 생성, 그리고 배고픔까지 전부 처리해. 하나의 큰 fall-through로 한 번에 다 해결해.

Bareiron 서버 코드 -- C 언어 6800줄

서버 틱과 몹 AI

handleServerTick 함수는 50ms(20 TPS)마다 호출돼. 메인 루프가 플레이어를 처리하는 동안 세계를 관리해:

// main.c (단순화)

void handleServerTick (uint32_t delta) {
  // 각 몹 업데이트
  for (int i = 0; i < MOB_COUNT; i ++) {
    if (mobs[i].type == 0 || mobs[i].data == 0) continue;  // 죽었거나 빈 슬롯

    MobData *mob = &mobs[i];
    int px, pz;
    getNearestPlayer(mob->x, mob->z, &px, &pz);

    if (mob->type == MOB_ZOMBIE) {
      // 적대적: 가장 가까운 플레이어에게 걸어감
      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 ++;
      // 2블록 내 접촉 데미지
      if (abs(px - mob->x) <= 2 && abs(pz - mob->z) <= 2)
        damagePlayer(getNearestPlayerId(mob->x, mob->z), 3);
    } else {
      // 수동적: 8방향 무작위
      uint8_t dir = getMobDir(mob);
      mob->x += dir_lookup[dir][0];
      mob->z += dir_lookup[dir][1];
      // 약 40틱마다 방향 변경
      if (mob->data >> 6 < 1) setMobDir(mob, rand() & 7);
      mob->data = (mob->data & 0x3F) | ((mob->data - 0x40) & 0xC0);
    }

    // 몹 주변 청크 깨우기
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

적대적 몹의 AI는 좌표 비교야. 말 그대로 if (px < x) x--. 경로 탐색 없음, A* 없음, 장애물 회피 없음. 좀비가 플레이어를 향해 X와 Z를 독립적으로 조정해 -- 벽이 있으면 그냥 통과해.

접촉 데미지는 초당 3하트야. p2r3가 일부러 높게 설정했는데, 경로 탐색이 없어서 좀비를 카이팅하기 쉽기 때문이야.

방어구 공식은 전투 업데이트 이전의 것 -- 가능한 가장 단순한 버전:

// main.c (단순화)

static uint8_t applyArmor (uint8_t damage, uint16_t armor_value) {
  // 1.9 이전 공식: 선형 감소
  // 방어구 포인트 당 4% 감소, 최대 80%
  uint8_t reduction = (armor_value * 4);
  if (reduction > 80) reduction = 80;
  return damage * (100 - reduction) / 100;
}

풀 다이아몬드 = 80% 감소. 좀비 한 대 3하트가 0.6하트가 돼. p2r3가 이 구식 공식을 선택한 이유는 2번의 연산으로 계산되기 때문이야 -- 임계값 없음, 곡선 없음, 그냥 선형 백분율.

수동적 몹: 룩업 테이블의 8방향, 약 40틱마다 방향 변경. data 필드는 상위 2비트에 현재 방향을, 나머지 6비트에 방향 변경 타이머를 인코딩해.

Bareiron의 몹들 -- 좀비, 돼지, 양

몹 리스폰

몹은 랜덤 틱으로 스폰되지 않아. 서버 틱이 새 청크 경계를 만날 때 나타나:

if (플레이어가 청크 경계를 넘었음) {
  for (int i = 0; i < MOB_COUNT; i ++) {
    if (mobs[i].type != 0) continue;
    spawnMob(&mobs[i], 새_청크_좌표, getChunkHash(cx, cz));
    break;
  }
}

지형과 같은 RNG, 같은 청크 시드. 몹 슬롯이 비어 있으면 스폰은 결정론적이야.

제작: 행렬 없음, if/else만

// crafting.c, 라인 9-347 (단순화)

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // 0x80 플래그가 설정되어 있으면, 제작 버퍼가 상자에 사용 중
  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:  /* 판자, 주괴... */
    case 2:  /* 막대기, 가위, 횃불 */
    case 3:  /* 삽, 검, 반 블록 */
    case 4:  /* 제작대, 부츠 */
    case 5:  /* 곡괭이, 도끼, 투구 */
    case 7:  /* 레깅스, 퇴비통 */
    case 8:  /* 화로, 상자, 흉갑 */
    case 9:  /* 전체 블록 (철, 금 등) */
  }
}

첫 번째 체크: 0x80 플래그가 설정되어 있으면, 제작 버퍼가 상자 포인터로 재활용된 거야. 제작 불가.

그 다음, 채워진 슬롯 수를 세고, 첫 번째 아이템을 기록하며, 동일성을 확인해. 이것만으로 화로를 4번 체크로 매칭할 수 있어:

if (count == 8 && first == 조약돌 && all_identical && center_empty)
    return 화로;

복잡한 형태는 첫 번째 아이템의 인덱스를 사용해 상대적 위치를 확인해. 레시피는 동일한 매칭 함수를 공유해 -- 재료가 결과를 결정해.

Bareiron의 제작 및 상자 인터페이스

상자: 진정한 해킹

모두가 말하는 메모리 해킹, 실제 코드로 보면:

// procedures.c, 라인 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!!
  // POINTER를 플레이어의 제작 아이템 배열에 복사
  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);
  }
}

그리고 코드의 주석: // Terrible memory hack!!1!

말 그대로 그거야. block_changes[]에서 다음 항목의 메모리 주소를 가져와 player->craft_items에 복사해 (이는 uint16_t[9], 즉 18바이트 -- 32비트 포인터를 저장하기에 충분해), 그리고 아무도 그동안 제작을 시도하지 못하도록 플래그를 설정해.

상자 인벤토리에서 클릭할 때마다:

// packets.c, 라인 620-638

uint8_t *storage_ptr;
memcpy(&storage_ptr, player->craft_items, sizeof(storage_ptr));
// storage_ptr이 이제 상자 데이터를 가리킴
uint16_t *p_item = (uint16_t *)(storage_ptr + (slot - 41) * 3);
uint8_t *p_count = storage_ptr + (slot - 41) * 3 + 2;

제작 버퍼에서 포인터를 꺼내고, 오프셋으로 슬롯에 접근해. 상자 데이터는 슬롯 당 3바이트(ID용 2, 개수용 1)로, 블록 배열 안에 서로 붙어서 저장돼.

블록 배열에 저장된 상자 데이터 -- 메모리 해킹

배고픔: 5줄의 천재성

// main.c, 라인 293-305

// 플레이어가 움직일 때는 초당 ~20개의 움직임 패킷을 보내고,
// 가만히 있을 때는 훨씬 적게 보낸다. 이를 활동과 연관지어
// 공짜로 배고픔을 시뮬레이션한다.
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) {  // 달리기
  player->saturation -= 1;
}

말 그대로 이게 전부야. 5줄. 움직임 패킷이 올 때마다 포만감이 감소해. 포만감이 0이 되면 배고픔이 줄고 포만감이 리셋돼. 달리기(0x08 플래그)는 소모를 두 배로 만들어.

타이머 제로, 할당 메모리 제로, 전용 계산 제로. 이미 존재하는 패킷에서 감소하는 카운터일 뿐이야.

낙하 데미지

프로젝트에서 가장 단순한 데미지 시스템:

// 플레이어가 땅에서 떨어질 때 Y를 저장
// 다시 땅에 닿을 때 차이를 뺌
데미지 = 마지막_땅_위_Y - 현재_Y;

뺄셈 하나.

블록 채굴과 설치

블록을 클릭하면 0x28 (Player Action) 패킷이 switch에 도착해. 핸들러는 해당 위치의 블록을 확인하고, 제거한 후 인벤토리에 아이템을 넣어야 해:

// main.c, case 0x28 (단순화)

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;
      }

      // block_changes에 추가
      addBlockChange(x, z, y, 0);  // 0 = 공기

      // 플레이어에게 아이템 지급 (클라이언트 신뢰)
      addItemToPlayer(client_fd, block_to_item(block), 1);

      // 클라이언트에 업데이트 전송
      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은 지형 생성과 플레이어 변경을 결합해:

uint8_t getBlockAt (int x, uint8_t y, int z) {
  // 먼저 플레이어 변경 확인
  uint8_t change = getBlockChange(x, y, z);
  if (change != 0xFF) return change;

  // 없으면 생성된 지형에서 읽기
  return getTerrainBlock(x, y, z);
}

변경이 우선, 지형이 폴백. 논쟁 제로, 캐시 제로, 오버헤드 제로. 내부의 getTerrainBlock은 getHeightAt + stone/dirt/grass/coal 레이어야.

즉석 화로

가장 재밌는 부분: 화로는 엔티티로서 존재하지 않아. "조리" 슬롯에 조약돌을, "연료"에 석탄을 넣으면 결과가 즉시 나타나. 타이머 없음, 청크 틱 없음. 그냥 올바른 아이템을 넣었을 때 비워지는 인벤토리 슬롯일 뿐이야.

즉석 화로 -- 재료 넣으면 즉시 결과

ESP32 루프: 4KB 스택의 MC 서버

// main.c, 라인 732-779

#ifdef ESP_PLATFORM

void bareiron_main (void *pvParameters) {
  main();
  vTaskDelete(NULL);
}

static void wifi_event_handler (...) {
  if (/* 연결됨 */) {
    xTaskCreate(bareiron_main, "bareiron", 4096, NULL, 5, NULL);
  }
}

void app_main () {
  esp_timer_early_init();
  wifi_init();
  // 나머지는 이벤트 핸들러가 처리
}
#endif

서버 전체가 4096바이트 스택의 FreeRTOS 태스크 안에서 돌아가. 그게 다야. 메인 스레드는 WiFi를 초기화하고 연결을 기다리는 일만 해. 연결되면 표준 main()을 호출하는 bareiron_main을 스폰해.

ESP32 특화 코드는 전부 #ifdef ESP_PLATFORM으로 보호돼. PC에서는 표준 POSIX 코드로 컴파일돼.

희생된 것들

이 모든 걸 맞추기 위해, 존재하지 않는 바닐라 기능들이 있어:

  • 네트워크 압축 없음 -- zlib이 너무 비쌈. 서버는 청크를 빠르게 생성하지만, 전송이 병목.
  • 랜덤 틱 없음 -- 나무는 뼛가루로만 자람. 몹은 청크 경계에서 스폰.
  • 아이템 엔티티 없음 -- 채굴한 블록은 바로 인벤토리로. 애니메이션은 순전히 시각적.
  • 인벤토리 검증 없음 -- 클라이언트를 신뢰. 다이아 64개? OK. 1초에 청크 하나 채굴? OK. 믿을 수 있는 사람끼리만 사용할 것.
  • 서버 측 조명 없음 -- 횃불은 다른 것들 다음에 전송되고, 클라이언트가 계산.
  • 점진적 유체 없음 -- 즉시 최종 상태.

최종 결과

Ryzen 5 3600: 청크 당 ~0.5ms. 1$ ESP32-C3: 청크 당 ~200ms. 플레이 가능.

청크 생성 벤치마크 -- Ryzen vs ESP32

3명 이상 플레이어: 버벅임. 작성자 왈, 피크 시간대의 2b2t와 비슷.

동일한 Bareiron 서버에 접속한 여러 플레이어

철학

p2r3: "0.5와트를 소비하는 이 아주 작은 1$ 칩이 마인크래프트만큼 고급진 무언가를 돌릴 수 있다는 생각이 그냥 좋아. 과학은 '왜'에 관한 게 아니야, '왜 안 될까'에 관한 거지."

모든 줄은 트레이드오프야:

  • Perlin noise → 보간: 덜 예쁘지만 200배 빠르고 메모리 제로
  • 제작 행렬 → 하드코딩 매칭: 코드는 지저분하지만 바이트 제로
  • zlib → 없음: 연결 안 좋으면 죽음, 하지만 플레이 가능
  • 검증 → 신뢰: 보안 제로, 계산 제로

없는 모든 기능이 다른 기능이 하드웨어 한계 안에 존재할 수 있게 해.

기억할 3가지:

  1. 보간 + RNG -- 4개 시드 점, 무한 지형, 저장 제로, 청크 재생성 없이 조회, 200ms 생성. 이게 모든 것을 가능하게 한 천재적인 움직임.
  2. 모든 기능에는 비용이 있다 -- 압축 없음, 랜덤 틱 없음, 검증 없음. 잊어버린 게 아니라, 520KB 안에 맞추기 위해 선택한 것.
  3. 지저분한 해킹이 가장 똑똑하다 -- memcpy로 블록 배열 속 상자, 움직임 패킷으로 배고픔, 즉석 화로. 깔끔한 해결책은 너무 비쌌을 거야.

프로젝트가 흥미롭다면, 모든 게 GitHub에 GPLv3 라이선스로 공개되어 있어. 아주 더러운 C 코드고, 소스 코드 읽으면서 이렇게 재미본 적이 거의 없었어 xD

Bareiron -- 1$'lık bir mikrodenetleyicide çalışan Minecraft sunucusu

6800 satır C, sıfır malloc, bilinear interpolasyonla değiştirilmiş

Giriş

Hiç bir Minecraft sunucusunu 1 liraya bir mikrodenetleyicide çalıştırabileceğini merak ettin mi?

Ben ettim. Ve cevap evet. Resmen.

Bareiron diye bir proje var, p2r3 imzalı, ve muhtemelen son yıllarda Minecraft dünyasında gördüğüm en büyüleyici projelerden biri. 300 kilobayta sığan bir binary'den bahsediyoruz, 6800 satır C, sıfır harici bağımlılık, malloc yok, threading yok, ve 1 dolarlık bir ESP32'de çalışıyor.

ESP32-C3, sunucuyu çalıştıran mikrodenetleyici

Sonsuz arazi üretimi. Biyomlar. Mağaralar. Craft. Madencilik. Mob'lar. Açlık. Sandıklar. Bir survival sunucusundan beklediğin her şey.

0.5 Watt tüketen ve 160 MHz saat hızına sahip bir çipte.

Fikir vermesi açısından: vanilla bir Minecraft sunucusu birkaç giga RAM'e ihtiyaç duyar. ESP32-C3 ise 520 KB SRAM'e sahip (boot'tan sonra 400'ü kullanılabilir). 20 yıl önceki işlemciler zaten gigahertz'de çalışıyordu -- bu 160 MHz'de takılı kalıyor. Saf güç açısından ikisi arasındaki fark yaklaşık 20 000.

p2r3 C dilinde bir Minecraft sunucusu yazmadı, sunucunun her bir tuğlasını bu kısıtlamalara sığacak şekilde yeniden icat etti. Kaynak kodu açarak nasıl yaptığına bakalım.

p2r3'ün Bareiron tanıtım videosunun küçük resmi

Projenin beyni: hafızasız arazi üretimi

Gömülü bir MC sunucusu yapmak istediğinde en büyük sorun arazi üretimi.

Vanilla Minecraft'ta dünya Perlin gürültüsü ile üretilir: üst üste bindirilmiş birkaç katman (oktavlar), 6 biyomik parametre (sıcaklık, nem, karasallık, erozyon, tuhaflık, derinlik) ve her seferinde her şeyi yeniden hesaplamamak için kocaman bir önbellekleme sistemi.

Sonuç muhteşem. Ama hesaplama açısından pahalı ve üretilen chunk'ları depolamak için RAM gerektiriyor.

Bareiron'un yaklaşımı radikal biçimde farklı. Gürültü yığmak yerine, deterministik bir RNG tarafından üretilen 4 nokta üzerinde bilinear interpolasyon kullanıyor.

Küçük pikselli bir resmi büyüttüğünde kenarların bulanıklaştığını bilirsin? Aynen öyle.

// worldgen.c, satırlar 117-171 (basitleştirilmiş)

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 koordinatları
  int _z = floor(z / CHUNK_SIZE);
  int rx = x % CHUNK_SIZE;          // chunk içi offset
  int rz = z % CHUNK_SIZE;
  uint32_t hash = getChunkHash(_x, _z);
  uint8_t biome = getChunkBiome(_x, _z);
  // hash + biome ile tohumlanmış 4 köşe arasında interpolasyon
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

Standart bilinear interpolasyon: 4 köşe, pozisyona göre ağırlıklar, çıktıda tek bir uint8_t. CHUNK_SIZE 8, yani tamsayı çarpmalarıyla yapılıyor, float yok.

p2r3 bunu videoda adım adım gösteriyor: önce chunk'ın 4 köşesi, her biri RNG tarafından tohumlanmış bir yüksekliğe sahip.

Chunk'ın 4 köşesi, her biri deterministik RNG tarafından tohumlanmış

Sonra bu 4 nokta arasındaki interpolasyon sürekli bir yüzey oluşturuyor.

4 köşe arasında bilinear interpolasyon uygulaması

Ve bu deseni tüm bitişik chunk'larda tekrarlayarak sonsuza uzanan bir arazi elde ediyorsun.

Nihai sonuç: sürekli düzensiz arazi

Deterministik RNG

Bunu mümkün kılan anahtar şey tohumlama. Her chunk'ın 4 köşesi var ve her köşenin benzersiz ama tekrarlanabilir bir sözde rastgele değere ihtiyacı var.

// worldgen.c, satırlar 13-22

uint32_t getChunkHash (short x, short z) {
  uint8_t buf[8];
  memcpy(buf, &x, 2);          // 16 bit X koordinatı
  memcpy(buf + 2, &z, 2);      // 16 bit Z koordinatı
  memcpy(buf + 4, &world_seed, 4);  // 32 bit global tohum
  return splitmix64(*((uint64_t *)buf));  // hash
}

16 bit X, 16 bit Z ve 32 bit tohumu 8 baytlık bir buffer'a paketliyor ve hepsini splitmix64'ten geçiriyor. Sonuç: dünyanın tohumuna dayalı olarak her pozisyon için deterministik benzersiz bir değer.

Olayın gücünü anlıyor musun? Sunucunun araziyi depolaması gerekmiyor. Oyuncu yeni bir bölgeye geldiğinde anında yeniden hesaplıyor ve her seferinde tam olarak aynı sonucu veriyor.

Kullanılan splitmix64, 64 bit hash'ler için tasarlanmış ultra hızlı bir prng:

// worldgen.c (basitleştirilmiş)

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 işlem: toplama, xor/kaydırma, çarpma, xor/kaydırma, çarpma, xor/kaydırma. Lookup table yok, döngü yok. 8 baytlık buffer'ı (X + Z + tohum) alıyor, 64 bitlik bir tamsayı olarak işliyor ve 32 bit hash döndürüyor. Deterministik, hızlı ve 5 satıra sığıyor.

Neden Perlin gürültüsü değil

p2r3'ün videoda kendi dediği gibi: "rastgele sayıya ne kadar çok basamak eklersen, arazi o kadar düzenli hale gelir, tıpkı daha fazla yazı tura atmanın seni 50/50'ye yaklaştırması gibi". Pratikte, birleştirdiği hash bitlerinin sayısı:

// worldgen.c, satırlar 51-115

// Plains biyomu için: 4 birleştirilmiş faktör → düzenli arazi
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Snowy plains için: 2 faktör → daha engebeli
h = (hash % 5) + ((hash >> 4) % 5);

Her biyom kaç bit çıkarımı birleştireceğini seçiyor. Ne kadar çok olursa, dağılım o kadar dengelenir -- tıpkı 50/50'ye yaklaşan daha fazla yazı tura atışı gibi. Az olduğunda, yerel varyasyonlar daha güçlü olur.

Düzensiz arazi -- az faktör, güçlü varyasyonlar

Sadece 2 faktörle, snowy plains neredeyse dağlık, inişli çıkışlı bir arazi üretiyor. Tepe ve çukurlar sık.

Düzenli arazi -- çoklu faktörler, pürüzsüz yüzey

4 faktörle, ovalar düz ve tahmin edilebilir kalıyor. Dağılım dengeleniyor.

Bir chunk, ESP32'de 200 ms'de üretiliyor -- aynı donanımda Perlin gürültüsüyle ölçülemeyecek kadar pahalıyken.

Can alıcı detay: tüm chunk'ı üretmeden bir bloğu sorgulamak

Oynuyorsun, bir bloğu kazıyorsun. Sunucu sana hangi item'i vereceğini bilmeli. Safça, bunun için tüm chunk'ı üretmek gerekirdi.

Bilinear interpolasyonla, düzlemdeki herhangi bir noktayı doğrudan koordinatlardan sorgulayabiliyorsun. Chunk'ın köşeleri oyuncunun konumundan elde ediliyor, interpolasyon sana herhangi bir offset'teki yüksekliği veriyor. Bir avuç matematik işlemi, chunk üretimi yok.

p2r3: "istediğim şey, bana verilen bir koordinattaki bloğu söyleyebilecek, hafızaya erişmeden veya pahalı gürültü haritaları hesaplamadan çalışan sihirli bir fonksiyon". Aynen yaptığı şey.

İşte yüksekliğin somut bloklara dönüşmesi:

// worldgen.c (basitleştirilmiş)

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 koşul. Bir katman grass/dirt/stone/deepslate/bedrock. Yüzey bloğu biome_top[] aracılığıyla biyoma bağlı -- ovalar için grass, çöl için sand. Döngü yok, switch yok, doğru katmana düşen bir if basamaklaması.

Mağaralar, en tembel mirror

mağara_yüksekliği = CAVE_BASE_DEPTH - (yüzey_yüksekliği - y);

Yüzey yüksekliğini yer altında aynalıyor. Büyük deepslate boşluklarına benziyor. Sıfır hesaplama, tek satır.

Yüzey arazisinin aynalanmasıyla oluşturulan mağaralar

Mağara oluşturmak için arazi aynalama şeması

Cevherler, XOR versiyonu

aday = (chunk_x ^ col_x ^ col_z) % 100;
if (aday < 5 && y < 16) -> diamond

Koordinatların XOR'u sütun başına bir aday garantiliyor. Tür sadece yüksekliğe bağlı. Elmaslar mağaraların en alt noktasının altına saklanmış, böylece kazmak mantıklı kalıyor.

Biyomlar tile map'te

Her biyom bir ızgarada dairesel bir ada, türü tohumdan hesaplanan bir desenle belirleniyor. Izgaralı, tahmin edilebilir ve beleş.

Tile map biyom haritası -- her ada farklı bir biyom

Her biyomun dizilerde kodlanmış kendi parametre seti var:

// worldgen.c (basitleştirilmiş)

static const uint8_t biome_base[] = {
  [BIOME_PLAINS]  = 48,   // taban yükseklik: 48
  [BIOME_DESERT]  = 52,   // biraz daha yüksek
  [BIOME_FOREST]  = 50,   // ikisinin arası
  [BIOME_TAIGA]   = 46,   // biraz daha alçak
  [BIOME_SNOWY]   = 40,   // en alçak
};

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 çıkarım → çok düzenli
  [BIOME_DESERT]  = 3,   // 3 çıkarım → orta
  [BIOME_FOREST]  = 4,   // 4 çıkarım → düzenli, inişli çıkışlı
  [BIOME_TAIGA]   = 3,   // 3 çıkarım → orta
  [BIOME_SNOWY]   = 2,   // 2 çıkarım → çok engebeli
};

Plains: yükseklik 48, 4 faktör → çok düz arazi, çimen.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Sonuç: maksimum ±4 blok varyasyon

Desert: yükseklik 52, 3 faktör, yüzey bloğu = kum. Asla deniz seviyesinin altında değil.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// Sonuç: maksimum ±6 blok varyasyon, SEA_LEVEL+1'e kenetlenmiş

Forest: yükseklik 50, plains gibi 4 faktör ama taban daha yüksek → ormanlık tepeler.

Taiga: yükseklik 46, 3 faktör → orta varyasyonlar, soğuk arazi.

Snowy plains: yükseklik 40, sadece 2 faktör → en engebelisi.

h = (hash % 5) + ((hash >> 4) % 5);
// Sonuç: maksimum ±14 blok varyasyon

Her biyom 3 dizi, 5 giriş ile kodlanmış: taban yükseklik, yüzey bloğu, faktör sayısı. getHeightAtFromHash biyomu aldığında, arazinin ayarlanması için bu dizilere bakıyor. Minecraft'ın tüm biyom sistemini değiştirmek için 15 byte veri.

Biyom dedektörü, her chunk'a hangi biyomun karşılık geldiğini belirlemek için tohumu kullanıyor:

// worldgen.c (basitleştirilmiş)

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];
}

16 girişlik bir desen, chunk koordinatlarıyla tohumlanmış bir index. Tekrarlı ama görsel olarak tutarlı bir ızgara veriyor. Vanilla Minecraft'ın tüm biyomik parametre sistemini değiştirmek için 4 satır kod.

getHeightAtFromHash: arazi montajcısı

Üretimin kalbindeki fonksiyon, biyomla tohumlanmış 4 köşeyi birleştiriyor:

// worldgen.c (basitleştirilmiş)

static uint8_t getHeightAtFromHash (int rx, int rz, short cx, short cz,
                                    uint32_t h, uint8_t biome) {
  // Hashten çıkarılan 4 köşe, her köşe için farklı tohum
  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);

  // Biyom kısıtlaması: çöl asla su altında değil
  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);
  }

  // 4 köşeden interpolasyon
  return interpolate(h1, h2, h3, h4, rx, rz);
}

Her biyomun referans yüksekliği kaydıran bir biome_base'i var ve 4 köşe farklı kaydırmalarla hashten çıkarılıyor. Çöl, minimumu deniz seviyesinin üstüne zorluyor -- ek biyomik hesaplama gerektirmeden suyu önleyen tek satırlık bir kısıtlama.

Ağaçlar ve kaktüsler: olasılıksal yerleştirme

Yüzey üretimi, nereye dikeceğine karar vermek için aynı chunk hash'ini kullanıyor:

// worldgen.c (basitleştirilmiş)

static void genFoliage (uint8_t *chunk_data, short cx, short cz,
                        uint32_t hash, uint8_t biome) {
  if (biome == BIOME_DESERT) {
    // Kaktüs: chunk başına bir aday, hash pozisyonu belirliyor
    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 {
    // Ağaçlar: hash koyup koymayacağını ve nereye koyacağını belirliyor
    int tree_count = (hash & 3);  // chunk başına 0-3 ağaç
    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);
    }
  }
}

Yeşil biyomlar için chunk başına 0-3 ağaç, çöl için maksimum 1 kaktüs. Chunk'ın hash'i tek entropi kaynağı -- chunk içindeki pozisyon için & 7, sayaç için & 3. Her şey deterministik, hiçbir şey depolanmıyor.

generateChunk: hepsini birleştirmek

8×8×256 blokluk tam bir chunk üretmek için her şeyi bir araya getiren fonksiyon:

// worldgen.c (basitleştirilmiş)

void generateChunk (uint8_t *chunk, short cx, short cz) {
  uint32_t hash = getChunkHash(cx, cz);
  uint8_t biome = getChunkBiome(cx, cz);

  // Chunk'taki her sütun için (8×8 = 64)
  for (int x = 0; x < 8; x ++) {
    for (int z = 0; z < 8; z ++) {
      // Mutlak dünya koordinatları
      int wx = cx * 8 + x;
      int wz = cz * 8 + z;

      // Sütun yüksekliği
      uint8_t height = getHeightAt(wx, wz);

      // Sütunu aşağıdan yukarıya doldur
      for (int y = 0; y < height; y ++) {
        uint8_t block = getTerrainBlock(wx, y, wz);
        chunk[y * 64 + z * 8 + x] = block;
      }
    }
  }

  // Yüzey öğelerini ekle (ağaçlar, kaktüsler)
  genFoliage(chunk, cx, cz, hash, biome);
}

Hepsi bu kadar. 3 iç içe döngü: her sütun için, yüksekliği bul, blokları doldur, bir sonrakine geç. Çıktı, tam chunk'ı temsil eden bir uint8_t[16384] (8 × 8 × 256). Önbellekleme yok, lazy loading yok, sıkıştırma yok -- chunk üretilir ve direkt istemciye gönderilir.

Depolama: her yerde statik diziler

Bareiron'un bellek mimarisi, tüm ihtişamıyla gömülü C. Malloc yok, hash map yok, bağlı liste yok.

Her şey sabit boyutlu global dizilerde.

Blok değişiklikleri

// globals.h, satırlar 191-196

typedef struct {
  short x;      // 2 byte -- yatayda 32 000 blok sınırı
  short z;      // 2 byte
  uint8_t y;    // 1 byte -- dikeyde 256 blok sınırı
  uint8_t block; // 1 byte -- 256 blok türü sınırı
} BlockChange;

20 000 giriş, yani yaklaşık 25 000 değişiklik -- tamamen kazılmış bir buçuk chunk'a eşdeğer. 0xFF değerindeki block alanı boş bir girişi işaretler. Arama doğrusal taramadır:

Blok dizisinin bellek düzeni -- giriş başına 6 byte

// 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;  // sandık verilerini atla
    #endif
  }
  return 0xFF;
}

Değişiklik eklemek de arama kadar doğrudan:

```c
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 ++;
}

Bir sayaç, bir index, bir yazma. Sıralama yok, sıkıştırma yok, bellek yönetimi yok. Dizi dolduğunda, yeni değişiklikler yok sayılır -- arazi üretilmiş haline döner.

Yazarın 256 blok sınırı hakkındaki yorumu: "hafifçe patinalanmış cilalı bakır merdivenleri henüz uygulamayı düşünmüyorum."

Mob'lar: kelle başına 8 byte

// globals.h, satırlar 240-251 (padding'i yok etmek için pragma pack(push, 1))

typedef struct {
  uint8_t type;   // 25=tavuk, 28=inek, 95=domuz, 106=koyun, 145=zombi
  short x;
  uint8_t y;      // health=0 ise, Y silinmeden önce bir zamanlayıcı olur
  short z;
  uint8_t data;   // bit 0-4: can, bit 5: koyun kırkılmış, bit 6-7: panik zamanlayıcısı
} MobData;

8 byte. Maksimum 16 yuva. Hizalama yok, padding yok. data byte'ı ev yapımı bir bitfield: 5 bit can, 1 bit kırkma, 2 bit panik zamanlayıcısı. Bir mob öldüğünde, Y alanı silinmeden önce bir zamanlayıcı olur. Bit seviyesinde bellek yeniden kullanımı.

Oyuncular: sıkışık paketlenmiş

Oyuncu verileri de #pragma pack(push, 1) kullanır -- short + uint8 cinsinden koordinatlar, uint16_t + uint8_t sabit dizilerinde envanterler ve aynı anda saldırı bekleme süresini, spawn durumunu, sneak, sprint, eat, load, movement cooldown ve craft kilidini kodlayan bir flags alanı. Bunların hepsi tek tek bitlerde.

Ana döngü: while(true) ve bloklamayan

Tüm sunucu tek bir döngüde, tek bir thread'de, sıfır event library ile çalışıyor.

// main.c, satırlar 594-720

while (true) {
  task_yield();  // ESP32'de watchdog'u rahat bırak

  // Yeni bir bağlantı kabul et (bloklamayan)
  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;
  }

  // Zaman dolduysa sunucu tick'i
  if (get_program_time() - last_tick_time > TIME_BETWEEN_TICKS) {
    handleServerTick(time_since_last_tick);
    last_tick_time = get_program_time();
  }

  // Round-robin: her iterasyonda bir istemci, bir paket
  client_index = (client_index + 1) % MAX_PLAYERS;
  if (clients[client_index] == -1) continue;

  // Paket başlığını oku: uzunluk + 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);
}

Döngünün her iterasyonunda tek bir istemci işlenir ve aynı anda tek bir paket okunur. Döngünun başındaki task_yield(), FreeRTOS boşta kalma görevinin ESP32'de nefes almasını sağlar -- bu olmadan watchdog timer çipi sıfırlar.

Paket dağıtımı, 400 satırlık devasa bir switch:

// main.c, satırlar 68-497

void handlePacket (int client_fd, int length, int packet_id, int state) {
  switch (packet_id) {
    case 0x00:  // Duruma göre Handshake / Status / Login
    case 0x01:  // Status ping
    case 0x02:  // Plugin mesajı
    case 0x03:  // Login/konfigürasyon onayı
    case 0x08:  // Sohbet
    case 0x0B:  // İstemci durumu (respawn)
    case 0x11:  // Container tıklama (sandıkları yönetir)
    case 0x19:  // Entity ile etkileşim
    case 0x1D..0x20:  // Hareket paketleri (en büyük durum)
    case 0x28:  // Oyuncu eylemi (kaz/yerleştir)
    // ... 40+ durum
  }
}

Dinamik jump table yok, vtable yok, map yok. Bir switch, statik jump table'a derlenir. Gömülü için mükemmel.

0x1D-0x20 durumu en büyüğü -- konum güncellemelerini, düşüş hasarını, chunk sınırı geçişlerini, mob spawn'ını, chunk üretimini VE açlığı yönetir. Hepsi tek bir büyük fall-through'da.

Bareiron sunucu kodu -- 6800 satır C

Sunucu tick'i ve mob yapay zekası

handleServerTick fonksiyonu her 50 ms'de bir (20 TPS) çağrılır. Ana döngü oyuncularla ilgilenirken dünyayı yönetir:

// main.c (basitleştirilmiş)

void handleServerTick (uint32_t delta) {
  // Her mob'u güncelle
  for (int i = 0; i < MOB_COUNT; i ++) {
    if (mobs[i].type == 0 || mobs[i].data == 0) continue;  // ölü veya boş

    MobData *mob = &mobs[i];
    int px, pz;
    getNearestPlayer(mob->x, mob->z, &px, &pz);

    if (mob->type == MOB_ZOMBIE) {
      // Düşman: en yakın oyuncuya yürü
      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 ++;
      // 2 blokta temas hasarı
      if (abs(px - mob->x) <= 2 && abs(pz - mob->z) <= 2)
        damagePlayer(getNearestPlayerId(mob->x, mob->z), 3);
    } else {
      // Pasif: 8 rastgele yön
      uint8_t dir = getMobDir(mob);
      mob->x += dir_lookup[dir][0];
      mob->z += dir_lookup[dir][1];
      // ~40 tick'te bir yön değiştir
      if (mob->data >> 6 < 1) setMobDir(mob, rand() & 7);
      mob->data = (mob->data & 0x3F) | ((mob->data - 0x40) & 0xC0);
    }

    // Mob'ın etrafındaki chunk'ları uyandır
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

Düşman mob'ların yapay zekası, bir koordinat karşılaştırması. Resmen if (px < x) x--. Yol bulma yok, A* yok, engel kaçınma yok. Zombi, X ve Z'yi bağımsız olarak oyuncuya doğru ayarlar -- varsa duvarların içinden geçer.

Temas hasarı saniyede 3 kalp. p2r3 bunu yüksek tutmayı seçmiş çünkü yol bulma olmaması zombileri kitesi kolay hale getiriyor.

Zırh formülü, combat update öncesindeki en basit hal:

// main.c (basitleştirilmiş)

static uint8_t applyArmor (uint8_t damage, uint16_t armor_value) {
  // 1.9 öncesi formül: doğrusal azaltma
  // Her zırh puanı = %4 azaltma, maksimum %80
  uint8_t reduction = (armor_value * 4);
  if (reduction > 80) reduction = 80;
  return damage * (100 - reduction) / 100;
}

Full diamond = %80 azaltma. 3 kalplık bir zombi vuruşu 0.6 kalp olur. p2r3 bu eski formülü seçmiş çünkü 2 işlemde hesaplanıyor -- eşik yok, eğri yok, sadece doğrusal bir yüzde.

Pasif mob'lar: bir arama tablosunda 8 yön, ~40 tick'te bir yön değiştirme. data alanı, geçerli yönü üstteki 2 bitte ve yön değiştirme zamanlayıcısını kalan 6 bitte kodlar.

Bareiron'daki mob'lar -- zombiler, domuzlar, koyunlar

Mob'ların yeniden doğması

Mob'lar rastgele tick'lerle spawn olmaz. Sunucu tick'i yeni bir chunk sınırıyla karşılaştığında ortaya çıkarlar:

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;
  }
}

Araziyle aynı RNG, aynı chunk tohumu. Bir mob yuvası boşsa, spawn deterministiktir.

Craft: matris yok, if/else var

// crafting.c, satırlar 9-347 (basitleştirilmiş)

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // 0x80 bayrağı kaldırılmışsa, craft buffer'ı bir sandık tarafından kullanılıyor
  if (player->flags & 0x80) { *count = 0; *item = 0; return; }

  // Yuvaları say, ilk item'i bul, kimliği doğrula
  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:  /* tahtalar, külçeler... */
    case 2:  /* çubuklar, makaslar, meşaleler */
    case 3:  /* kürekler, kılıçlar, döşemeler */
    case 4:  /* craft masası, botlar */
    case 5:  /* kazmalar, baltalar, kasklar */
    case 7:  /* pantolonlar, kompostlar */
    case 8:  /* fırın, sandık, zırh */
    case 9:  /* tam bloklar (demir, altın, vb.) */
  }
}

İlk kontrol: 0x80 bayrağı kaldırılmışsa, craft buffer'ı sandık işaretçisi olarak geri dönüştürülür. Craft mümkün değil.

Sonra doldurulan yuvaları sayar, ilk item'i not eder, kimliği doğrular. Sadece bununla, fırını 4 kontrolde eşleştirirsin:

if (count == 8 && first == cobblestone && all_identical && center_empty)
    return furnace;

Karmaşık şekiller için, ilk item'in index'ini kullanır ve göreceli pozisyonu kontrol eder. Tarifler aynı eşleme fonksiyonunu paylaşır -- malzeme sonucu belirler.

Bareiron'da craft ve sandık arayüzü

Sandıklar: gerçek hack

Herkesin bahsettiği bellek hack'i, gerçek koduyla:

// procedures.c, satırlar 1262-1293

if (target == B_chest) {
  // Blok dizisinde sandık girişini ara
  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]);  // sandık bloğunun sonrasını işaret et
    break;
  }
  if (storage_ptr == NULL) return;

  // Terrible memory hack!!
  // POINTER'I oyuncunun craft item dizisine kopyala
  memcpy(player->craft_items, &storage_ptr, sizeof(storage_ptr));
  player->flags |= 0x80;  // craft'ı kilitle

  // İstemciye sandık arayüzünü gönder
  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);
  }
}

Ve koddaki yorum: // Terrible memory hack!!1!

Aynen öyle. block_changes[] içindeki bir sonraki girişin bellek adresini alıyor, player->craft_items'e kopyalıyor (ki bu bir uint16_t[9], yani 18 byte -- 32 bitlik bir işaretçiyi depolamak için yeterli) ve bu süre boyunca kimsenin craft yapmaya çalışmaması için bayrağı kaldırıyor.

Sandık envanterindeki her tıklamada:

// packets.c, satırlar 620-638

uint8_t *storage_ptr;
memcpy(&storage_ptr, player->craft_items, sizeof(storage_ptr));
// storage_ptr şimdi sandık verilerini işaret ediyor
uint16_t *p_item = (uint16_t *)(storage_ptr + (slot - 41) * 3);
uint8_t *p_count = storage_ptr + (slot - 41) * 3 + 2;

İşaretçiyi craft buffer'ından geri alıyor ve yuvalara bir offset ile erişiyor. Sandık verileri, blok dizisinde yan yana, yuva başına 3 byte (2'si ID, 1'i miktar) olarak depolanıyor.

Blok dizisinde depolanan sandık verileri -- bir bellek hack'i

Açlık: 5 satır deha

// main.c, satırlar 293-305

// Oyuncular hareket ederken ~20/saniye, hareketsizken çok daha az
// hareket paketi gönderir. Açlığı bedavaya simüle etmek için
// bunu aktiviteyle ilişkilendiriyoruz.
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) {  // sprint
  player->saturation -= 1;
}

Resmen bu kadar. 5 satır. Her hareket paketi doygunluğu azaltır. Doygunluk sıfıra ulaştığında, açlık düşer ve doygunluk sıfırlanır. Sprint (0x08 bayrağı) tüketimi ikiye katlar.

Sıfır zamanlayıcı, sıfır ayrılmış bellek, sıfır özel hesaplama. Zaten var olan paketler üzerinde azalan bir sayaç.

Düşüş hasarı

Projenin en basit hasar sistemi:

// Oyuncu yerden ayrıldığında Y'sini sakla
// Yere tekrar değdiğinde çıkar
hasar = son_yerdeki_y - mevcut_y;

Bir çıkarma işlemi.

Blok kazma ve yerleştirme

Bir bloğa tıkladığında, 0x28 (Player Action) paketi switch'e düşer. İşleyici, pozisyondaki bloğu belirlemeli, onu kaldırmalı ve item'i envantere koymalı:

// main.c, case 0x28 (basitleştirilmiş)

void handlePlayerAction (int client_fd, uint8_t action, int x, uint8_t y, int z) {
  switch (action) {
    case START_DESTROY_BLOCK: {
      // Tıklanan pozisyondaki blok türünü belirle
      uint8_t block = getBlockAt(x, y, z);

      if (block == B_chest) {
        openChest(client_fd, x, y, z);
        break;
      }

      // block_changes'e ekle
      addBlockChange(x, z, y, 0);  // 0 = air

      // Oyuncuya item'i ver (client'a güven)
      addItemToPlayer(client_fd, block_to_item(block), 1);

      // İstemciye güncellemeyi gönder
      sc_blockChange(client_fd, x, y, z, 0);
      sc_ackBlockChange(client_fd, x, y, z, 0);
      break;
    }
    case PLACE_BLOCK: {
      // Blok türünü oyuncunun elinden oku
      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, arazi üretimini VE oyuncu değişikliklerini birleştirir:

uint8_t getBlockAt (int x, uint8_t y, int z) {
  // Önce oyuncu değişikliklerini kontrol et
  uint8_t change = getBlockChange(x, y, z);
  if (change != 0xFF) return change;

  // Yoksa, üretilen araziden oku
  return getTerrainBlock(x, y, z);
}

Değişikliklere öncelik, araziye geri dönüş. Sıfır tartışma, sıfır önbellek, sıfır ek yük. Kaputun altındaki getTerrainBlock, getHeightAt + stone/dirt/grass/coal katmanlarıdır.

Anında fırın

En komiği: fırın bir entity olarak mevcut değil. "Pişirme" yuvasına cobblestone ve "yakıt" yuvasına coal koyarsan, sonuç anında belirir. Zamanlayıcı yok, chunk ticking yok. Doğru item'leri koyduğunda boşalan bir envanter yuvası.

Anında fırın -- malzemeleri koy, sonuç anında

ESP32 döngüsü: 4 KB stack'te bir MC sunucusu

// main.c, satırlar 732-779

#ifdef ESP_PLATFORM

void bareiron_main (void *pvParameters) {
  main();
  vTaskDelete(NULL);
}

static void wifi_event_handler (...) {
  if (/* bağlandı */) {
    xTaskCreate(bareiron_main, "bareiron", 4096, NULL, 5, NULL);
  }
}

void app_main () {
  esp_timer_early_init();
  wifi_init();
  // Gerisi event handler tarafından yönetilir
}
#endif

Tüm sunucu, 4096 byte stack ile bir FreeRTOS görevinde çalışır. Hepsi bu. Ana main thread sadece WiFi'i başlatır ve bir bağlantı bekler. Bağlantı kurulduğunda, standart main()'i çağıran bareiron_main'i spawn eder.

ESP32'ye özgü tüm kod, #ifdef ESP_PLATFORM ile korunur. PC'de bunların hepsi standart POSIX koduna derlenir.

Feda edilenler

Bunların hepsinin sığması için, var olmayan vanilla özellikleri var:

  • Ağ sıkıştırması yok -- zlib çok pahalı. Sunucu chunk'ları hızlı üretir ama göndermek darboğazdır.
  • Rastgele tick yok -- ağaçlar bone meal ile büyür ya da büyümez. Mob'lar chunk sınırlarında spawn olur.
  • Item entity'si yok -- kazılan bloklar doğrudan envantere gider. Animasyon tamamen görseldir.
  • Hiçbir envanter doğrulaması yok -- client'a güven. 64 elmas? Sorun değil. 1 saniyede kazılmış bir chunk? Sorun değil. Güvendiğin kişilerle kullanılacak.
  • Sunucu tarafı ışık yok -- meşaleler her şeyden sonra gönderilir, istemci hesaplar.
  • Aşamalı sıvı yok -- anında nihai durum.

Nihai sonuç

Ryzen 5 3600: chunk başına ~0.5 ms. 1$'lık ESP32-C3: chunk başına ~200 ms. Oynanabilir.

Chunk üretimi karşılaştırması -- Ryzen vs ESP32

3+ oyuncu: kasıyor. Yazarın deyimiyle, 2b2t'nin yoğun saatleriyle karşılaştırılabilir.

Aynı Bareiron sunucusuna bağlı birden çok oyuncu

Felsefe

p2r3: "Sadece 0.5 Watt tüketen bu küçücük 1$'lık çipin Minecraft kadar gelişmiş bir şeyi çalıştırabilmesi fikrini seviyorum. Science isn't about 'why', it's about 'why not'."

Her satır bir takas:

  • Perlin gürültüsü → interpolasyon: daha az güzel, 200 kat hızlı, sıfır bellek
  • Craft matrisleri → hardcodlanmış eşleme: iğrenç kod, sıfır byte
  • zlib → hiçbir şey: kötü bağlantı = ölüm, ama oynanabilir
  • Doğrulama → güven: sıfır güvenlik, sıfır hesaplama

Eksik her özellik, bir başkasının donanım sınırları içinde var olmasını sağlıyor.

Unutulmaması gereken 3 şey:

  1. Interpolasyon + RNG -- 4 tohumlanmış nokta, sonsuz arazi, sıfır depolama, chunk'ı yeniden üretmeden sorgulama, 200 ms üretim. Geri kalan her şeyi mümkün kılan deha hamlesi.
  2. Her özelliğin bir maliyeti var -- Sıkıştırma yok, rastgele tick yok, doğrulama yok. Bunlar unutkanlık değil, 520 KB'da kalmanın bedeli.
  3. En iğrenç hack'ler en zekileridir -- memcpy ile blok dizisindeki sandıklar, hareket paketleriyle açlık, anında fırın. Temiz çözüm çok pahalı olurdu.

Proje ilgini çekiyorsa, her şey GitHub'da GPLv3 lisansıyla. Oldukça pis bir C kodu ve bir kaynak kodu okumaktan bu kadar zevk aldığım nadir olmuştur xD

Bareiron -- il server Minecraft che gira su un microcontrollore da 1$

6800 righe di C, zero malloc, Perlin noise sostituito da interpolazione

Introduzione

Ti sei mai chiesto se si potesse far girare un server Minecraft su un microcontrollore da 1 euro?

Io sì. E la risposta è sì. Letteralmente.

C'è un progetto che si chiama Bareiron, firmato p2r3, ed è probabilmente uno dei progetti più affascinanti che abbia visto nel mondo Minecraft negli ultimi anni. Parliamo di un binario che sta in 300 kilobyte, 6800 righe di C, zero dipendenze esterne, niente malloc, niente threading, e gira su una ESP32 da 1 dollaro.

ESP32-C3, il microcontrollore che fa girare il server

Generazione di terreno infinita. Biomi. Grotte. Craft. Miniera. Mob. Fame. Bauli. Tutto quello che ti aspetti da un server survival.

Su un chip che consuma 0.5 Watt e ha 160 MHz di clock.

Per darti un'idea: un server Minecraft vanilla ha bisogno di diversi gigabyte di RAM. L'ESP32-C3 ha 520 KB di SRAM (400 disponibili dopo il boot). I processori 20 anni fa giravano già in gigahertz -- questo arriva a 160 MHz. Il fattore tra i due in potenza pura è circa 20.000.

p2r3 non ha scritto un server Minecraft in C, ha reinventato ogni singolo mattone del server per farlo stare in questi vincoli. Vediamo come, aprendo il codice sorgente.

Miniatura del video di presentazione di Bareiron di p2r3

Il cervello del progetto: una generazione di terreno senza memoria

Il problema più grande quando vuoi fare un server MC embedded è la generazione del terreno.

In Minecraft vanilla, il mondo è generato con Perlin noise: diversi strati sovrapposti (ottave), 6 parametri biomici (temperatura, umidità, continentalità, erosione, weirdness, profondità), e tutto un sistema di caching per non dover ricalcolare tutto ogni volta.

Il risultato è magnifico. Ma è costoso in termini di calcolo, e occupa RAM per memorizzare i chunk generati.

L'approccio di Bareiron è radicalmente diverso. Invece di impilare rumore, usa l'interpolazione bilineare su 4 punti generati da un RNG deterministico.

Sai quando ingrandisci una piccola immagine pixelata e i bordi diventano sfocati? Esattamente così.

// worldgen.c, righe 117-171 (semplificato)

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);  // coordinate chunk
  int _z = floor(z / CHUNK_SIZE);
  int rx = x % CHUNK_SIZE;          // offset dentro il chunk
  int rz = z % CHUNK_SIZE;
  uint32_t hash = getChunkHash(_x, _z);
  uint8_t biome = getChunkBiome(_x, _z);
  // interpolazione tra 4 angoli seedati da hash + biome
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

L'interpolazione bilineare standard: 4 angoli, pesi in base alla posizione, un singolo uint8_t in output. CHUNK_SIZE è 8, quindi si fa con moltiplicazioni intere, niente float.

p2r3 lo mostra passo dopo passo nel video: prima i 4 angoli del chunk, ognuno con un'altezza seedata dall'RNG.

I 4 angoli del chunk, ognuno seedato dall'RNG deterministico

Poi l'interpolazione tra questi 4 punti crea una superficie continua.

Applicazione dell'interpolazione bilineare tra i 4 angoli

E ripetendo il pattern su tutti i chunk adiacenti, otteniamo un terreno che si estende all'infinito.

Risultato finale: terreno irregolare continuo

L'RNG deterministico

La chiave che rende tutto possibile è il seeding. Ogni chunk ha 4 angoli, e ogni angolo ha bisogno di un valore pseudo-casuale unico ma riproducibile.

// worldgen.c, righe 13-22

uint32_t getChunkHash (short x, short z) {
  uint8_t buf[8];
  memcpy(buf, &x, 2);          // 16 bit di coordinata X
  memcpy(buf + 2, &z, 2);      // 16 bit di coordinata Z
  memcpy(buf + 4, &world_seed, 4);  // 32 bit di seed globale
  return splitmix64(*((uint64_t *)buf));  // hash
}

Impacchetta i 16 bit di X, 16 bit di Z, e 32 bit di seed, in un buffer di 8 byte, e passa il tutto in splitmix64. Risultato: un valore deterministico unico per ogni posizione, basato sul seed del mondo.

Cogli la potenza del coso? Il server non ha bisogno di memorizzare il terreno. Ricalcola al volo quando il giocatore arriva in una nuova zona, e dà esattamente lo stesso risultato ogni volta.

Lo splitmix64 usato è un prng ultra-veloce progettato per hash a 64 bit:

// worldgen.c (semplificato)

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 operazioni: addizione, xor/shift, moltiplicazione, xor/shift, moltiplicazione, xor/shift. Niente lookup table, niente loop. Prende il buffer di 8 byte (X + Z + seed), lo tratta come un intero a 64 bit, e restituisce 32 bit di hash. È deterministico, veloce, e sta in 5 righe.

Perché non è Perlin noise

p2r3 lo dice lui stesso nel video: "più cifre del numero casuale aggiungi, più il terreno diventa regolare, come più lanci di moneta ti avvicinano al 50/50". In pratica, è il numero di bit dell'hash che combina:

// worldgen.c, righe 51-115

// Per un bioma plains: 4 fattori combinati → terreno regolare
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Per snowy plains: 2 fattori → più accidentato
h = (hash % 5) + ((hash >> 4) % 5);

Ogni bioma sceglie quante estrazioni di bit combinare. Più ce ne sono, più la distribuzione si stabilizza -- come più lanci di moneta che si avvicinano al 50/50. Meno ce ne sono, più forti sono le variazioni locali.

Terreno irregolare -- pochi fattori, variazioni forti

Con solo 2 fattori, la snowy plains produce un terreno collinare, quasi montuoso. Picchi e avvallamenti sono frequenti.

Terreno regolare -- fattori multipli, superficie liscia

Con 4 fattori, le pianure rimangono piatte e prevedibili. La distribuzione si stabilizza.

Un chunk si genera in 200 ms su ESP32 -- contro un tempo non misurabile sullo stesso hardware con Perlin noise, tanto è costoso.

Il dettaglio che spacca: interrogare un blocco senza generare tutto il chunk

Giochi, mini un blocco. Il server deve sapere quale item darti. Ingenuamente, bisognerebbe generare tutto il chunk per farlo.

Con l'interpolazione bilineare, interroghi qualsiasi punto del piano direttamente dalle coordinate. Gli angoli del chunk si ottengono dalla posizione del giocatore, l'interpolazione ti dà l'altezza a qualsiasi offset. Una manciata di operazioni matematiche, niente generazione di chunk.

p2r3: "quello che voglio è una funzione magica che possa dirmi quale blocco si trova a una data coordinata, senza accedere alla memoria né calcolare costose mappe di rumore". Esattamente quello che ha fatto.

Ecco come l'altezza diventa blocchi concreti:

// worldgen.c (semplificato)

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 condizioni. Uno strato di grass/dirt/stone/deepslate/bedrock. Il blocco di superficie dipende dal bioma tramite biome_top[] -- grass per le pianure, sand per il deserto. Niente loop, niente switch, una cascata di if che cade nello strato giusto.

Le grotte, mirror più pigro

altitudine_grotta = CAVE_BASE_DEPTH - (altezza_superficie - y);

Specchia l'altezza della superficie sottoterra. Assomiglia alle grandi cavità di deepslate. Zero calcolo, una riga.

Grotte generate dal mirror del terreno di superficie

Schema del mirror del terreno per generare le grotte

I minerali, versione XOR

candidato = (chunk_x ^ col_x ^ col_z) % 100;
if (candidato < 5 && y < 16) -> diamond

Uno XOR di coordinate garantisce un candidato per colonna. Il tipo dipende solo dall'altitudine. I diamanti sono nascosti sotto il punto più basso delle grotte per far sì che scavare rimanga utile.

I biomi in tile map

Ogni bioma è un'isola circolare in una griglia, il suo tipo determinato da un pattern calcolato dal seed. Grigliato, prevedibile, e gratuito.

Mappa dei biomi in tile map -- ogni isola è un bioma diverso

Ogni bioma ha il proprio set di parametri codificato in array:

// worldgen.c (semplificato)

static const uint8_t biome_base[] = {
  [BIOME_PLAINS]  = 48,   // altezza base: 48
  [BIOME_DESERT]  = 52,   // leggermente più alto
  [BIOME_FOREST]  = 50,   // tra i due
  [BIOME_TAIGA]   = 46,   // un po' più basso
  [BIOME_SNOWY]   = 40,   // il più basso
};

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 estrazioni → molto regolare
  [BIOME_DESERT]  = 3,   // 3 estrazioni → moderato
  [BIOME_FOREST]  = 4,   // 4 estrazioni → regolare, collinare
  [BIOME_TAIGA]   = 3,   // 3 estrazioni → moderato
  [BIOME_SNOWY]   = 2,   // 2 estrazioni → molto accidentato
};

Plains: altezza 48, 4 fattori → terreno molto piatto, erba.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Risultato: variazione massima di ±4 blocchi

Desert: altezza 52, 3 fattori, blocco superficie = sabbia. Mai sotto il livello del mare.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// Risultato: variazione massima di ±6 blocchi, clampato a SEA_LEVEL+1

Forest: altezza 50, 4 fattori come plains ma base più alta → colline boscose.

Taiga: altezza 46, 3 fattori → variazioni moderate, terreno freddo.

Snowy plains: altezza 40, solo 2 fattori → il più accidentato.

h = (hash % 5) + ((hash >> 4) % 5);
// Risultato: variazione massima di ±14 blocchi

Ogni bioma è codificato in 3 array da 5 voci: altezza base, blocco superficie, numero di fattori. Quando getHeightAtFromHash riceve il bioma, consulta questi array per regolare il terreno. 15 byte di dati per sostituire tutto il sistema di biomi di Minecraft.

Il rilevatore di bioma usa il seed per determinare quale bioma corrisponde a ogni chunk:

// worldgen.c (semplificato)

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];
}

Un pattern di 16 voci, un indice seedato dalle coordinate del chunk. Dà una griglia ripetitiva ma visivamente coerente. 4 righe di codice per sostituire tutto il sistema di parametri biomici di Minecraft vanilla.

getHeightAtFromHash: l'assemblatore di terreno

La funzione al centro della generazione combina i 4 angoli seedati per bioma:

// worldgen.c (semplificato)

static uint8_t getHeightAtFromHash (int rx, int rz, short cx, short cz,
                                    uint32_t h, uint8_t biome) {
  // 4 angoli estratti dall'hash, seed diverso per angolo
  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);

  // Vincolo bioma: deserto mai sott'acqua
  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);
  }

  // Interpolazione dai 4 angoli
  return interpolate(h1, h2, h3, h4, rx, rz);
}

Ogni bioma ha una biome_base che sposta l'altezza di riferimento, e i 4 angoli sono estratti dall'hash con offset diversi. Il deserto forza il minimo sopra il livello del mare -- una riga di vincolo che evita l'acqua senza calcolo biomico aggiuntivo.

Alberi e cactus: posizionamento probabilistico

La generazione di superficie usa lo stesso hash del chunk per decidere dove piantare:

// worldgen.c (semplificato)

static void genFoliage (uint8_t *chunk_data, short cx, short cz,
                        uint32_t hash, uint8_t biome) {
  if (biome == BIOME_DESERT) {
    // Cactus: un candidato per chunk, hash determina la posizione
    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 {
    // Alberi: hash determina se e dove posizionarli
    int tree_count = (hash & 3);  // 0-3 alberi 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 alberi per chunk per i biomi verdi, 1 cactus massimo per il deserto. L'hash del chunk è l'unica fonte di entropia -- un & 7 per la posizione nel chunk, un & 3 per il contatore. Tutto è deterministico, niente viene memorizzato.

generateChunk: mettere tutto insieme

La funzione che mette tutto insieme per produrre un chunk completo di 8×8×256 blocchi:

// worldgen.c (semplificato)

void generateChunk (uint8_t *chunk, short cx, short cz) {
  uint32_t hash = getChunkHash(cx, cz);
  uint8_t biome = getChunkBiome(cx, cz);

  // Per ogni colonna del chunk (8×8 = 64)
  for (int x = 0; x < 8; x ++) {
    for (int z = 0; z < 8; z ++) {
      // Coordinate mondo assolute
      int wx = cx * 8 + x;
      int wz = cz * 8 + z;

      // Altezza della colonna
      uint8_t height = getHeightAt(wx, wz);

      // Riempire la colonna dal basso verso l'alto
      for (int y = 0; y < height; y ++) {
        uint8_t block = getTerrainBlock(wx, y, wz);
        chunk[y * 64 + z * 8 + x] = block;
      }
    }
  }

  // Aggiungere gli elementi di superficie (alberi, cactus)
  genFoliage(chunk, cx, cz, hash, biome);
}

Tutto qui. 3 loop annidati: per ogni colonna, trovare l'altezza, riempire i blocchi, passare alla successiva. L'output è un uint8_t[16384] (8 × 8 × 256) che rappresenta il chunk completo. Niente caching, niente lazy loading, niente compressione -- il chunk è generato e inviato direttamente al client.

Lo storage: array statici ovunque

L'architettura di memoria di Bareiron è C embedded in tutto il suo splendore. Niente malloc, niente hash map, niente liste concatenate.

Tutto è in array globali di dimensione fissa.

Le modifiche ai blocchi

// globals.h, righe 191-196

typedef struct {
  short x;      // 2 byte -- limite a 32.000 blocchi orizzontale
  short z;      // 2 byte
  uint8_t y;    // 1 byte -- limite a 256 blocchi verticale
  uint8_t block; // 1 byte -- limite a 256 tipi di blocco
} BlockChange;

20.000 voci, pari a circa 25.000 modifiche -- l'equivalente di un chunk e mezzo interamente scavato. Il campo block a 0xFF segna una voce libera. La ricerca è una scansione lineare:

Layout di memoria dell'array di blocchi -- 6 byte per voce

// 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;  // salta dati baule
    #endif
  }
  return 0xFF;
}

Aggiungere una modifica è altrettanto diretto quanto la ricerca:

```c
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 ++;
}

Un contatore, un indice, una scrittura. Niente ordinamento, niente compattazione, niente gestione della memoria. Quando l'array è pieno, le nuove modifiche vengono ignorate -- il terreno torna al suo stato generato.

Il commento dell'autore sul limite a 256 blocchi: "non ho intenzione di implementare le scale in rame leggermente ossidate e lucidate tanto presto."

I mob: 8 byte a testa

// globals.h, righe 240-251 (pragma pack(push, 1) per eliminare il padding)

typedef struct {
  uint8_t type;   // 25=chicken, 28=cow, 95=pig, 106=sheep, 145=zombie
  short x;
  uint8_t y;      // se health=0, Y diventa un timer prima della rimozione
  short z;
  uint8_t data;   // bit 0-4: health, bit 5: sheep sheared, bit 6-7: panic timer
} MobData;

8 byte. 16 slot massimi. Niente allineamento, niente padding. Il byte data è un bitfield fatto in casa: 5 bit di vita, 1 bit di tosatura, 2 bit di timer di panico. E quando un mob muore, il campo Y diventa un timer prima della rimozione. Riutilizzo della memoria a livello di bit.

I giocatori: impacchettati stretti

Anche i dati giocatore usano #pragma pack(push, 1) -- coordinate in short + uint8_t, inventari in array fissi di uint16_t + uint8_t, e un campo flags che codifica insieme il cooldown d'attacco, lo stato di spawn, sneak, sprint, eat, load, movement cooldown, e il lock di craft. Tutto in bit individuali.

Il loop principale: while(true) e non bloccante

Il server intero gira su un loop, un thread, zero event library.

// main.c, righe 594-720

while (true) {
  task_yield();  // lascia respirare il watchdog su ESP32

  // Accettare una nuova connessione (non bloccante)
  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;
  }

  // Tick server se il tempo è scaduto
  if (get_program_time() - last_tick_time > TIME_BETWEEN_TICKS) {
    handleServerTick(time_since_last_tick);
    last_tick_time = get_program_time();
  }

  // Round-robin: un client, un pacchetto per iterazione
  client_index = (client_index + 1) % MAX_PLAYERS;
  if (clients[client_index] == -1) continue;

  // Leggere l'intestazione del pacchetto: 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);
}

Un solo client viene processato per iterazione del loop, e un solo pacchetto viene letto alla volta. Il task_yield() all'inizio del loop lascia respirare il task idle di FreeRTOS su ESP32 -- senza questo, il watchdog timer ti resetta il chip.

Il dispatch dei pacchetti è uno switch mostruoso di 400 righe:

// main.c, righe 68-497

void handlePacket (int client_fd, int length, int packet_id, int state) {
  switch (packet_id) {
    case 0x00:  // Handshake / Status / Login a seconda dello stato
    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 (gestisce i bauli)
    case 0x19:  // Interact entity
    case 0x1D..0x20:  // Movement packets (il caso più grande)
    case 0x28:  // Player action (scava/piazza)
    // ... 40+ casi
  }
}

Niente jump table dinamica, niente vtable, niente mappa. Uno switch compila in jump table statica. Perfetto per l'embedded.

Il caso 0x1D-0x20 è il più grande -- gestisce gli aggiornamenti di posizione, i danni da caduta, gli attraversamenti dei confini di chunk, lo spawn dei mob, la generazione di chunk, E la fame. Tutto in un unico grande fall-through.

Il codice del server Bareiron -- 6800 righe di C

Il tick del server e l'IA dei mob

La funzione handleServerTick viene chiamata ogni 50 ms (20 TPS). Gestisce il mondo mentre il loop principale si occupa dei giocatori:

// main.c (semplificato)

void handleServerTick (uint32_t delta) {
  // Aggiornare ogni mob
  for (int i = 0; i < MOB_COUNT; i ++) {
    if (mobs[i].type == 0 || mobs[i].data == 0) continue;  // morto o vuoto

    MobData *mob = &mobs[i];
    int px, pz;
    getNearestPlayer(mob->x, mob->z, &px, &pz);

    if (mob->type == MOB_ZOMBIE) {
      // Ostile: cammina verso il giocatore più vicino
      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 ++;
      // Danni da contatto a 2 blocchi
      if (abs(px - mob->x) <= 2 && abs(pz - mob->z) <= 2)
        damagePlayer(getNearestPlayerId(mob->x, mob->z), 3);
    } else {
      // Passivo: 8 direzioni casuali
      uint8_t dir = getMobDir(mob);
      mob->x += dir_lookup[dir][0];
      mob->z += dir_lookup[dir][1];
      // Cambio direzione ogni ~40 tick
      if (mob->data >> 6 < 1) setMobDir(mob, rand() & 7);
      mob->data = (mob->data & 0x3F) | ((mob->data - 0x40) & 0xC0);
    }

    // Risvegliare i chunk intorno al mob
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

L'IA dei mob ostili è un confronto di coordinate. Letteralmente if (px < x) x--. Niente pathfinding, niente A*, niente obstacle avoidance. Lo zombie aggiusta X e Z indipendentemente verso il giocatore -- attraversa i muri se ci sono.

I danni da contatto sono a 3 cuori/sec. p2r3 l'ha voluto alto perché l'assenza di pathfinding rende gli zombies facili da kirare.

La formula dell'armatura è quella precedente al combat update -- la più semplice possibile:

// main.c (semplificato)

static uint8_t applyArmor (uint8_t damage, uint16_t armor_value) {
  // Formula pre-1.9: riduzione lineare
  // Ogni punto armatura = 4% di riduzione, max 80%
  uint8_t reduction = (armor_value * 4);
  if (reduction > 80) reduction = 80;
  return damage * (100 - reduction) / 100;
}

Full diamond = 80% di riduzione. Un colpo di zombie a 3 cuori diventa 0.6 cuori. p2r3 ha scelto questa vecchia formula perché si calcola in 2 operazioni -- niente soglie, niente curve, solo una percentuale lineare.

I mob passivi: 8 direzioni in una lookup table, cambio di rotta ogni ~40 tick. Il campo data codifica la direzione in corso nei 2 bit più significativi, e il timer di cambio direzione nei 6 bit rimanenti.

Mob in Bareiron -- zombie, maiali, pecore

Il respawn dei mob

I mob non spawnano con random tick. Appaiono quando il tick del server incontra un nuovo confine di chunk:

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;
  }
}

Stesso RNG del terreno, stesso seed di chunk. Se uno slot mob è libero, lo spawn è deterministico.

Il craft: niente matrici, solo if/else

// crafting.c, righe 9-347 (semplificato)

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // Se il flag 0x80 è alzato, il buffer di craft è usato da un baule
  if (player->flags & 0x80) { *count = 0; *item = 0; return; }

  // Contare gli slot, trovare il primo item, verificare l'identità
  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:  /* assi, lingotti... */
    case 2:  /* bastoni, cesoie, torce */
    case 3:  /* pale, spade, lastre */
    case 4:  /* tavolo da lavoro, stivali */
    case 5:  /* picconi, asce, elmi */
    case 7:  /* gambiere, composter */
    case 8:  /* fornace, baule, corazza */
    case 9:  /* blocchi completi (ferro, oro, ecc.) */
  }
}

Il primo controllo: se il flag 0x80 è alzato, il buffer di craft è riciclato come puntatore a baule. Niente craft possibile.

Poi conta gli slot riempiti, nota il primo item, verifica l'identità. Con solo questo, matchi il fornello in 4 controlli:

if (count == 8 && first == cobblestone && all_identical && center_empty)
    return furnace;

Per le forme complesse, usa l'indice del primo item e controlla la posizione relativa. Le ricette condividono la stessa funzione di matching -- il materiale determina il risultato.

Interfaccia di craft e baule in Bareiron

I bauli: l'hack vero

L'hack di memoria di cui tutti parlano, in vero codice:

// procedures.c, righe 1262-1293

if (target == B_chest) {
  // Cercare la voce del baule nell'array dei blocchi
  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]);  // punta dopo il blocco baule
    break;
  }
  if (storage_ptr == NULL) return;

  // Terrible memory hack!!
  // Copia il PUNTATORE nell'array di item di craft del giocatore
  memcpy(player->craft_items, &storage_ptr, sizeof(storage_ptr));
  player->flags |= 0x80;  // blocca il craft

  // Inviare l'interfaccia baule al 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);
  }
}

E il commento nel codice: // Terrible memory hack!!1!

È esattamente questo. Prende l'indirizzo di memoria della voce successiva in block_changes[], lo copia in player->craft_items (che è un uint16_t[9], quindi 18 byte -- abbastanza per memorizzare un puntatore a 32 bit), e alza il flag perché nessuno provi a craftare durante questo periodo.

A ogni clic nell'inventario del baule:

// packets.c, righe 620-638

uint8_t *storage_ptr;
memcpy(&storage_ptr, player->craft_items, sizeof(storage_ptr));
// storage_ptr ora punta ai dati del baule
uint16_t *p_item = (uint16_t *)(storage_ptr + (slot - 41) * 3);
uint8_t *p_count = storage_ptr + (slot - 41) * 3 + 2;

Recupera il puntatore dal buffer di craft, e accede agli slot con un offset. I dati del baule sono memorizzati a 3 byte per slot (2 per l'ID, 1 per la quantità), incollati uno dopo l'altro nell'array di blocchi.

Dati del baule memorizzati nell'array di blocchi -- un hack di memoria

La fame: 5 righe di genio

// main.c, righe 293-305

// I giocatori inviano pacchetti di movimento a ~20/sec quando
// si muovono, molto meno quando sono fermi. Correliamo questo
// con l'attività per simulare la fame gratuitamente.
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;
}
}

È letteralmente questo. 5 righe. Ogni pacchetto di movimento decrementa la saturazione. Quando la saturazione arriva a zero, la fame scende e si resetta la saturazione. Lo sprint (flag `0x08`) raddoppia il consumo.

Zero timer, zero memoria allocata, zero calcolo dedicato. Un contatore che si decrementa su pacchetti che esistono già.

### I danni da caduta

Il sistema di danni più semplice del progetto:

```c
// Quando il giocatore lascia il suolo, memorizziamo la sua Y
// Quando tocca di nuovo il suolo, sottraiamo
danni = ultima_y_al_suolo - y_attuale;

Una sottrazione.

Minare e piazzare blocchi

Quando clicchi su un blocco, il pacchetto 0x28 (Player Action) atterra nello switch. Il gestore deve determinare quale blocco si trova alla posizione, rimuoverlo, e mettere l'item nell'inventario:

// main.c, case 0x28 (semplificato)

void handlePlayerAction (int client_fd, uint8_t action, int x, uint8_t y, int z) {
  switch (action) {
    case START_DESTROY_BLOCK: {
      // Determinare il tipo di blocco alla posizione cliccata
      uint8_t block = getBlockAt(x, y, z);

      if (block == B_chest) {
        openChest(client_fd, x, y, z);
        break;
      }

      // Aggiungere a block_changes
      addBlockChange(x, z, y, 0);  // 0 = air

      // Dare l'item al giocatore (trust the client)
      addItemToPlayer(client_fd, block_to_item(block), 1);

      // Inviare l'aggiornamento al client
      sc_blockChange(client_fd, x, y, z, 0);
      sc_ackBlockChange(client_fd, x, y, z, 0);
      break;
    }
    case PLACE_BLOCK: {
      // Leggere il tipo di blocco dalla mano del giocatore
      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 combina la generazione di terreno E le modifiche dei giocatori:

uint8_t getBlockAt (int x, uint8_t y, int z) {
  // Prima verificare le modifiche dei giocatori
  uint8_t change = getBlockChange(x, y, z);
  if (change != 0xFF) return change;

  // Altrimenti, leggere dal terreno generato
  return getTerrainBlock(x, y, z);
}

Priorità alle modifiche, fallback sul terreno. Zero dibattito, zero cache, zero overhead. Il getTerrainBlock sotto il cofano è getHeightAt + gli strati di stone/dirt/grass/coal.

Il fornello istantaneo

Il più divertente: il fornello non esiste come entità. Se metti cobblestone nella casella "cottura" e coal nel "fuel", il risultato appare immediatamente. Niente timer, niente chunk ticking. È solo uno slot d'inventario che si svuota quando metti gli item giusti.

Fornello istantaneo -- metti gli ingredienti, risultato immediato

Il loop ESP32: un server MC in 4 KB di stack

// main.c, righe 732-779

#ifdef ESP_PLATFORM

void bareiron_main (void *pvParameters) {
  main();
  vTaskDelete(NULL);
}

static void wifi_event_handler (...) {
  if (/* connesso */) {
    xTaskCreate(bareiron_main, "bareiron", 4096, NULL, 5, NULL);
  }
}

void app_main () {
  esp_timer_early_init();
  wifi_init();
  // Il resto è gestito dall'event handler
}
#endif

Il server intero gira in un task FreeRTOS con 4096 byte di stack. È tutto. Il thread main principale fa solo inizializzare il WiFi e aspettare una connessione. Una volta connesso, spawna bareiron_main che chiama il main() standard.

Tutto il codice specifico ESP32 è protetto da #ifdef ESP_PLATFORM. Su PC, tutto compila in codice POSIX standard.

Cosa è stato sacrificato

Per far sì che tutto ci stia, ci sono feature vanilla che non esistono:

  • Niente compressione di rete -- zlib troppo costoso. Il server genera chunk velocemente, ma inviarli è il collo di bottiglia.
  • Niente random tick -- gli alberi crescono con bone meal o non crescono. I mob spawnano ai confini di chunk.
  • Niente entità item -- i blocchi minati vanno direttamente nell'inventario. L'animazione è puramente visiva.
  • Nessuna verifica d'inventario -- trust the client. 64 diamanti? OK. Un chunk minato in 1 sec? OK. Da usare tra persone di fiducia.
  • Niente luce lato server -- le torce sono inviate dopo tutto il resto, il client calcola.
  • Niente fluidi progressivi -- stato finale istantaneo.

Il risultato finale

Ryzen 5 3600: ~0.5 ms per chunk. ESP32-C3 da 1$: ~200 ms per chunk. Giocabile.

Benchmark di generazione chunk -- Ryzen vs ESP32

3+ giocatori: lagga. Paragonabile a 2b2t nelle ore di punta, dice l'autore.

Più giocatori connessi allo stesso server Bareiron

La filosofia

p2r3: "Mi piace solo l'idea che questo piccolissimo chip da 1$ che consuma 0.5 Watt possa far girare qualcosa di avanzato come Minecraft. Science isn't about 'why', it's about 'why not'."

Ogni riga è un tradeoff:

  • Perlin noise → interpolazione: meno bello, 200x più veloce, zero memoria
  • Matrici di craft → matching hardcodato: codice schifoso, zero byte
  • zlib → niente: connessione scarsa = morte, ma giocabile
  • Validazione → trust: zero sicurezza, zero calcolo

Ogni feature assente permette a un'altra di esistere nei limiti dell'hardware.

Le 3 cose da ricordare:

  1. Interpolazione + RNG -- 4 punti seedati, terreno infinito, zero storage, query senza rigenerare il chunk, 200 ms di generazione. È la mossa geniale che rende tutto il resto possibile.
  2. Ogni feature ha un costo -- Niente compressione, niente random tick, niente validazione. Non sono dimenticanze, è ciò che permette di stare in 520 KB.
  3. Gli hack schifosi sono i più intelligenti -- Bauli nell'array di blocchi tramite memcpy, fame tramite pacchetti di movimento, fornello istantaneo. La soluzione pulita sarebbe stata troppo costosa.

Se il progetto ti interessa, tutto è su GitHub in GPLv3. È C bello sporco, e raramente ho provato così tanto piacere a leggere un codice sorgente xD

Bareiron -- der Minecraft-Server auf einem 1$-Mikrocontroller

6800 Zeilen C, null malloc, Perlin Noise ersetzt durch bilineare

Einleitung

Hast du dich jemals gefragt, ob man einen Minecraft-Server auf einem 1$-Mikrocontroller laufen lassen kann?

Ich schon. Und die Antwort ist ja. Wirklich.

Es gibt ein Projekt namens Bareiron, von p2r3, und es ist wahrscheinlich eines der faszinierendsten Projekte, die ich in den letzten Jahren in der Minecraft-Welt gesehen habe. Wir reden von einer Binary, die in 300 Kilobyte passt, 6800 Zeilen C, null externe Abhängigkeiten, kein malloc, kein Threading, und das läuft auf einer ESP32 für 1 Dollar.

ESP32-C3, der Mikrocontroller, der den Server antreibt

Unendliche Terrain-Generierung. Biome. Höhlen. Crafting. Mining. Mobs. Hunger. Truhen. Alles, was du von einem Survival-Server erwartest.

Auf einem Chip, der 0.5 Watt verbraucht und 160 MHz Takt hat.

Zur Einordnung: Ein Vanilla-Minecraft-Server braucht mehrere Gigabyte RAM. Der ESP32-C3 hat 520 KB SRAM (400 verfügbar nach dem Booten). Prozessoren vor 20 Jahren takteten schon im Gigahertz-Bereich -- dieser hier erreicht maximal 160 MHz. Der reine Leistungsfaktor zwischen beiden liegt bei etwa 20.000.

p2r3 hat nicht einfach einen Minecraft-Server in C geschrieben, er hat jeden Baustein des Servers neu erfunden, damit alles in diese Grenzen passt. Lass uns mal reinschauen, wie er das gemacht hat -- im Quellcode.

Vorschaubild des Präsentationsvideos von Bareiron von p2r3

Das Gehirn des Projekts: Terrain-Generierung ohne Speicher

Das größte Problem, wenn du einen eingebetteten MC-Server bauen willst, ist die Terrain-Generierung.

In Minecraft Vanilla wird die Welt mit Perlin Noise generiert: mehrere übereinandergelegte Schichten (Oktaven), 6 biomische Parameter (Temperatur, Feuchtigkeit, Kontinentalität, Erosion, Weirdness, Tiefe) und ein ganzes Caching-System, um nicht alles jedes Mal neu berechnen zu müssen.

Das Ergebnis ist wunderschön. Aber es ist rechenintensiv und braucht RAM, um die generierten Chunks zu speichern.

Bareirons Ansatz ist radikal anders. Statt Noise zu stapeln, verwendet es bilineare Interpolation über 4 Punkte, die von einem deterministischen RNG erzeugt werden.

Kennst du das, wenn du ein kleines verpixeltes Bild vergrößerst und die Kanten verschwimmen? Genau das ist es.

// worldgen.c, Zeilen 117-171 (vereinfacht)

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-Koordinaten
  int _z = floor(z / CHUNK_SIZE);
  int rx = x % CHUNK_SIZE;          // Offset innerhalb des Chunks
  int rz = z % CHUNK_SIZE;
  uint32_t hash = getChunkHash(_x, _z);
  uint8_t biome = getChunkBiome(_x, _z);
  // Interpolation zwischen 4 Ecken, geseedet durch Hash + Biome
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

Standard-Bilineare Interpolation: 4 Ecken, Gewichte je nach Position, ein einziger uint8_t als Ausgabe. CHUNK_SIZE ist 8, also läuft das mit ganzzahligen Multiplikationen, kein Float.

p2r3 zeigt es Schritt für Schritt im Video: zuerst die 4 Ecken des Chunks, jede mit einer durch den RNG geseedeten Höhe.

Die 4 Ecken des Chunks, jede durch den deterministischen RNG geseedet

Dann erzeugt die Interpolation zwischen diesen 4 Punkten eine durchgehende Oberfläche.

Anwendung der bilinearen Interpolation zwischen den 4 Ecken

Und indem man das Muster auf allen benachbarten Chunks wiederholt, erhält man ein Terrain, das sich ins Unendliche erstreckt.

Endergebnis: durchgehendes unregelmäßiges Gelände

Der deterministische RNG

Der Schlüssel, der das alles möglich macht, ist das Seeding. Jeder Chunk hat 4 Ecken, und jede Ecke braucht einen eindeutigen, aber reproduzierbaren Pseudozufallswert.

// worldgen.c, Zeilen 13-22

uint32_t getChunkHash (short x, short z) {
  uint8_t buf[8];
  memcpy(buf, &x, 2);          // 16 Bit X-Koordinate
  memcpy(buf + 2, &z, 2);      // 16 Bit Z-Koordinate
  memcpy(buf + 4, &world_seed, 4);  // 32 Bit globaler Seed
  return splitmix64(*((uint64_t *)buf));  // Hash
}

Es packt die 16 Bit von X, 16 Bit von Z und 32 Bit Seed in einen 8-Byte-Puffer und gibt das Ganze durch splitmix64. Ergebnis: ein deterministischer, eindeutiger Wert für jede Position, basierend auf dem Welt-Seed.

Checkst du, wie mächtig das ist? Der Server muss das Terrain nicht speichern. Er berechnet es in Echtzeit nach, sobald der Spieler in eine neue Zone kommt, und es liefert jedes Mal exakt das gleiche Ergebnis.

Das verwendete splitmix64 ist ein ultraschneller PRNG, der für 64-Bit-Hashes entwickelt wurde:

// worldgen.c (vereinfacht)

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 Operationen: Addition, XOR/Shift, Multiplikation, XOR/Shift, Multiplikation, XOR/Shift. Keine Lookup-Tabelle, keine Schleife. Es nimmt den 8-Byte-Puffer (X + Z + Seed), behandelt ihn als 64-Bit-Integer und gibt 32 Bit Hash zurück. Deterministisch, schnell, und in 5 Zeilen.

Warum das kein Perlin Noise ist

p2r3 sagt es selbst im Video: "Je mehr Digits der Zufallszahl du hinzufügst, desto regelmäßiger wird das Terrain, so wie mehr Münzwürfe dich 50/50 annähern." In der Praxis ist es die Anzahl der Hash-Bits, die er kombiniert:

// worldgen.c, Zeilen 51-115

// Für ein Plains-Biom: 4 kombinierte Faktoren → gleichmäßiges Gelände
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Für verschneite Ebenen: 2 Faktoren → unebener
h = (hash % 5) + ((hash >> 4) % 5);

Jedes Biom wählt, wie viele Bit-Extraktionen es kombiniert. Je mehr, desto stabiler wird die Verteilung -- wie mehr Münzwürfe, die sich 50/50 annähern. Je weniger, desto stärker die lokalen Variationen.

Unregelmäßiges Gelände -- wenige Faktoren, starke Schwankungen

Mit nur 2 Faktoren erzeugen die verschneiten Ebenen ein hügeliges, fast bergiges Gelände. Spitzen und Täler sind häufig.

Regelmäßiges Gelände -- viele Faktoren, glatte Oberfläche

Mit 4 Faktoren bleiben die Ebenen flach und vorhersagbar. Die Verteilung stabilisiert sich.

Ein Chunk wird in 200 ms auf dem ESP32 generiert -- im Vergleich zu einer nicht messbaren Zeit auf derselben Hardware mit Perlin Noise, weil es so teuer ist.

Das Killer-Detail: einen Block abfragen, ohne den ganzen Chunk zu generieren

Du spielst, du baust einen Block ab. Der Server muss wissen, welches Item er dir geben soll. Naiv müsste man dafür den ganzen Chunk generieren.

Mit der bilinearen Interpolation kannst du jeden beliebigen Punkt der Ebene direkt aus den Koordinaten abfragen. Die Chunk-Ecken werden von der Spielerposition abgeleitet, die Interpolation liefert die Höhe an jedem beliebigen Offset. Eine Handvoll mathematischer Operationen, keine Chunk-Generierung.

p2r3: "Was ich will, ist eine magische Funktion, die mir sagen kann, welcher Block sich an einer bestimmten Koordinate befindet, ohne auf Speicher zuzugreifen oder teure Noise-Maps zu berechnen." Genau das hat er gebaut.

So wird aus der Höhe ein konkreter Block:

// worldgen.c (vereinfacht)

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 Bedingungen. Eine Schicht Grass/Dirt/Stone/Deepslate/Bedrock. Der Oberflächenblock hängt vom Biom ab, über biome_top[] -- Gras für die Ebene, Sand für die Wüste. Keine Schleife, kein Switch, eine Kaskade von Ifs, die in die richtige Schicht fällt.

Höhlen, der faulste Mirror

altitude_grotte = CAVE_BASE_DEPTH - (hauteur_surface - y);

Er spiegelt die Oberflächenhöhe unter die Erde. Das ergibt große Deepslate-Kavitäten. Null Rechenaufwand, eine Zeile.

Durch Spiegelung des Oberflächengeländes generierte Höhlen

Diagramm der Geländespiegelung zur Höhlengenerierung

Erze, XOR-Version

candidat = (chunk_x ^ col_x ^ col_z) % 100;
if (candidat < 5 && y < 16) -> diamond

Ein XOR der Koordinaten garantiert einen Kandidaten pro Säule. Der Typ hängt nur von der Höhe ab. Diamanten sind unter dem tiefsten Punkt der Höhlen versteckt, damit Graben sinnvoll bleibt.

Biome als Tilemap

Jedes Biom ist eine kreisförmige Insel in einem Raster, sein Typ bestimmt durch ein aus dem Seed berechnetes Muster. Gerastert, vorhersagbar und kostenlos.

Biomkarte als Tilemap -- jede Insel ist ein anderes Biom

Jedes Biom hat seinen eigenen Parametersatz, kodiert in Arrays:

// worldgen.c (vereinfacht)

static const uint8_t biome_base[] = {
  [BIOME_PLAINS]  = 48,   // Basishöhe: 48
  [BIOME_DESERT]  = 52,   // etwas höher
  [BIOME_FOREST]  = 50,   // dazwischen
  [BIOME_TAIGA]   = 46,   // etwas niedriger
  [BIOME_SNOWY]   = 40,   // am niedrigsten
};

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 Extraktionen → sehr gleichmäßig
  [BIOME_DESERT]  = 3,   // 3 Extraktionen → moderat
  [BIOME_FOREST]  = 4,   // 4 Extraktionen → gleichmäßig, hügelig
  [BIOME_TAIGA]   = 3,   // 3 Extraktionen → moderat
  [BIOME_SNOWY]   = 2,   // 2 Extraktionen → sehr uneben
};

Plains: Höhe 48, 4 Faktoren → sehr flaches Gelände, Gras.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Ergebnis: maximal ±4 Blöcke Variation

Desert: Höhe 52, 3 Faktoren, Oberflächenblock = Sand. Niemals unter dem Meeresspiegel.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// Ergebnis: maximal ±6 Blöcke Variation, auf SEA_LEVEL+1 begrenzt

Forest: Höhe 50, 4 Faktoren wie Plains, aber höhere Basis → bewaldete Hügel.

Taiga: Höhe 46, 3 Faktoren → moderate Variationen, kaltes Gelände.

Snowy plains: Höhe 40, nur 2 Faktoren → am unebensten.

h = (hash % 5) + ((hash >> 4) % 5);
// Ergebnis: maximal ±14 Blöcke Variation

Jedes Biom ist in 3 Arrays mit je 5 Einträgen kodiert: Basishöhe, Oberflächenblock, Anzahl Faktoren. Wenn getHeightAtFromHash das Biom erhält, greift es auf diese Arrays zu, um das Gelände anzupassen. 15 Bytes Daten, um das gesamte Biom-System von Minecraft zu ersetzen.

Der Biom-Detektor verwendet den Seed, um zu bestimmen, welches Biom zu welchem Chunk gehört:

// worldgen.c (vereinfacht)

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];
}

Ein Muster mit 16 Einträgen, ein Index, der durch die Chunk-Koordinaten geseedet wird. Das ergibt ein sich wiederholendes, aber visuell konsistentes Raster. 4 Zeilen Code, um das gesamte Biom-Parametersystem von Minecraft Vanilla zu ersetzen.

getHeightAtFromHash: Der Gelände-Assembler

Die Funktion im Herzen der Generierung kombiniert die 4 biom-geseedeten Ecken:

// worldgen.c (vereinfacht)

static uint8_t getHeightAtFromHash (int rx, int rz, short cx, short cz,
                                    uint32_t h, uint8_t biome) {
  // 4 Ecken aus dem Hash extrahiert, unterschiedlicher Seed pro Ecke
  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-Einschränkung: Wüste nie unter Wasser
  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);
  }

  // Interpolation aus den 4 Ecken
  return interpolate(h1, h2, h3, h4, rx, rz);
}

Jedes Biom hat eine biome_base, die die Referenzhöhe verschiebt, und die 4 Ecken werden mit unterschiedlichen Offsets aus dem Hash extrahiert. Die Wüste erzwingt ein Minimum über dem Meeresspiegel -- eine einzige Einschränkungszeile, die Wasser ohne zusätzliche biomische Berechnung vermeidet.

Bäume und Kakteen: probabilistische Platzierung

Die Oberflächengenerierung verwendet denselben Chunk-Hash, um zu entscheiden, wo gepflanzt wird:

// worldgen.c (vereinfacht)

static void genFoliage (uint8_t *chunk_data, short cx, short cz,
                        uint32_t hash, uint8_t biome) {
  if (biome == BIOME_DESERT) {
    // Kaktus: ein Kandidat pro Chunk, Hash bestimmt 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 {
    // Bäume: Hash bestimmt, ob und wo platziert wird
    int tree_count = (hash & 3);  // 0-3 Bäume pro 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 Bäume pro Chunk für grüne Biome, maximal 1 Kaktus für die Wüste. Der Chunk-Hash ist die einzige Entropiequelle -- ein & 7 für die Position im Chunk, ein & 3 für den Zähler. Alles deterministisch, nichts wird gespeichert.

generateChunk: Alles zusammenfügen

Die Funktion, die alles zusammensetzt, um einen kompletten 8×8×256-Chunk zu produzieren:

// worldgen.c (vereinfacht)

void generateChunk (uint8_t *chunk, short cx, short cz) {
  uint32_t hash = getChunkHash(cx, cz);
  uint8_t biome = getChunkBiome(cx, cz);

  // Für jede Spalte des Chunks (8×8 = 64)
  for (int x = 0; x < 8; x ++) {
    for (int z = 0; z < 8; z ++) {
      // Absolute Weltkoordinaten
      int wx = cx * 8 + x;
      int wz = cz * 8 + z;

      // Höhe der Spalte
      uint8_t height = getHeightAt(wx, wz);

      // Spalte von unten nach oben füllen
      for (int y = 0; y < height; y ++) {
        uint8_t block = getTerrainBlock(wx, y, wz);
        chunk[y * 64 + z * 8 + x] = block;
      }
    }
  }

  // Oberflächenelemente hinzufügen (Bäume, Kakteen)
  genFoliage(chunk, cx, cz, hash, biome);
}

Das war's. 3 verschachtelte Schleifen: für jede Spalte die Höhe finden, Blöcke füllen, weiter zur nächsten. Die Ausgabe ist ein uint8_t[16384] (8 × 8 × 256), der den kompletten Chunk repräsentiert. Kein Caching, kein Lazy Loading, keine Kompression -- der Chunk wird generiert und direkt an den Client gesendet.

Speicher: Überall statische Arrays

Die Speicherarchitektur von Bareiron ist Embedded-C in seiner ganzen Pracht. Kein malloc, keine Hashmaps, keine verketteten Listen.

Alles befindet sich in globalen Arrays mit fester Größe.

Die Block-Änderungen

// globals.h, Zeilen 191-196

typedef struct {
  short x;      // 2 Bytes -- begrenzt auf 32.000 Blöcke horizontal
  short z;      // 2 Bytes
  uint8_t y;    // 1 Byte -- begrenzt auf 256 Blöcke vertikal
  uint8_t block; // 1 Byte -- begrenzt auf 256 Blocktypen
} BlockChange;

20.000 Einträge, also etwa 25.000 Änderungen -- das entspricht eineinhalb komplett ausgegrabenen Chunks. Das Feld block mit 0xFF markiert einen freien Eintrag. Die Suche ist ein linearer Scan:

Speicherlayout des Block-Arrays -- 6 Bytes pro Eintrag

// 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;  // Truhendaten überspringen
    #endif
  }
  return 0xFF;
}

Das Hinzufügen einer Änderung ist genauso direkt wie die Suche:

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 ++;
}

Ein Zähler, ein Index, ein Schreibvorgang. Kein Sortieren, keine Kompaktierung, keine Speicherverwaltung. Wenn das Array voll ist, werden neue Änderungen ignoriert -- das Gelände kehrt in seinen generierten Zustand zurück.

Der Kommentar des Autors zur 256-Block-Grenze: "Ich habe nicht vor, in absehbarer Zeit leicht angerostete, gewachste Kupfertreppen zu implementieren."

Mobs: 8 Bytes pro Nase

// globals.h, Zeilen 240-251 (pragma pack(push, 1) zum Eliminieren von Padding)

typedef struct {
  uint8_t type;   // 25=chicken, 28=cow, 95=pig, 106=sheep, 145=zombie
  short x;
  uint8_t y;      // wenn health=0, wird Y zum Timer vor Löschung
  short z;
  uint8_t data;   // Bits 0-4: health, Bit 5: sheep sheared, Bits 6-7: panic timer
} MobData;

8 Bytes. Maximal 16 Plätze. Keine Ausrichtung, kein Padding. Das data-Byte ist ein selbstgebautes Bitfeld: 5 Bit Leben, 1 Bit geschoren, 2 Bit Panik-Timer. Und wenn ein Mob stirbt, wird das Y-Feld zu einem Timer vor der Löschung. Bit-Level-Speicherwiederverwendung.

Spieler: Dicht gepackt

Die Spielerdaten verwenden auch #pragma pack(push, 1) -- Koordinaten als short + uint8_t, Inventare als feste Arrays von uint16_t + uint8_t, und ein flags-Feld, das gleichzeitig den Angriffs-Cooldown, Spawn-Status, Schleichen, Sprinten, Essen, Laden, Bewegungs-Cooldown und Craft-Sperre kodiert. Alles in einzelnen Bits.

Die Hauptschleife: while(true) und nicht-blockierend

Der gesamte Server läuft in einer Schleife, einem Thread, null Event-Library.

// main.c, Zeilen 594-720

while (true) {
  task_yield();  // lässt den Watchdog auf dem ESP32 atmen

  // Neue Verbindung annehmen (nicht-blockierend)
  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, wenn die Zeit abgelaufen ist
  if (get_program_time() - last_tick_time > TIME_BETWEEN_TICKS) {
    handleServerTick(time_since_last_tick);
    last_tick_time = get_program_time();
  }

  // Round-Robin: ein Client, ein Paket pro Iteration
  client_index = (client_index + 1) % MAX_PLAYERS;
  if (clients[client_index] == -1) continue;

  // Paket-Header lesen: Länge + 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);
}

Nur ein Client wird pro Schleifeniteration bearbeitet, und es wird jeweils nur ein Paket gelesen. Das task_yield() am Anfang der Schleife lässt die FreeRTOS-Leerlaufaufgabe auf dem ESP32 atmen -- ohne das setzt der Watchdog-Timer den Chip zurück.

Das Paket-Dispatching ist ein monströser Switch mit 400 Zeilen:

// main.c, Zeilen 68-497

void handlePacket (int client_fd, int length, int packet_id, int state) {
  switch (packet_id) {
    case 0x00:  // Handshake / Status / Login je nach Zustand
    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 (verwaltet Truhen)
    case 0x19:  // Interact entity
    case 0x1D..0x20:  // Bewegungs-Pakete (der größte Fall)
    case 0x28:  // Player action (abbauen/platzieren)
    // ... 40+ Fälle
  }
}

Keine dynamische Jump-Tabelle, keine Vtable, keine Map. Ein Switch kompiliert zu einer statischen Sprungtabelle. Perfekt für Embedded.

Der Fall 0x1D-0x20 ist der größte -- er behandelt Positionsaktualisierungen, Fallschaden, Chunk-Grenzüberschreitungen, Mob-Spawning, Chunk-Generierung UND Hunger. Alles in einem einzigen großen Fall-Through.

Der Bareiron-Servercode -- 6800 Zeilen C

Der Server-Tick und die Mob-KI

Die Funktion handleServerTick wird alle 50 ms aufgerufen (20 TPS). Sie verwaltet die Welt, während sich die Hauptschleife um die Spieler kümmert:

// main.c (vereinfacht)

void handleServerTick (uint32_t delta) {
  // Jeden Mob aktualisieren
  for (int i = 0; i < MOB_COUNT; i ++) {
    if (mobs[i].type == 0 || mobs[i].data == 0) continue;  // tot oder leer

    MobData *mob = &mobs[i];
    int px, pz;
    getNearestPlayer(mob->x, mob->z, &px, &pz);

    if (mob->type == MOB_ZOMBIE) {
      // Feindlich: läuft zum nächsten Spieler
      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 ++;
      // Kontaktschaden bei 2 Blöcken
      if (abs(px - mob->x) <= 2 && abs(pz - mob->z) <= 2)
        damagePlayer(getNearestPlayerId(mob->x, mob->z), 3);
    } else {
      // Passiv: 8 zufällige Richtungen
      uint8_t dir = getMobDir(mob);
      mob->x += dir_lookup[dir][0];
      mob->z += dir_lookup[dir][1];
      // Richtungswechsel alle ~40 Ticks
      if (mob->data >> 6 < 1) setMobDir(mob, rand() & 7);
      mob->data = (mob->data & 0x3F) | ((mob->data - 0x40) & 0xC0);
    }

    // Chunks um den Mob herum aufwecken
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

Die KI feindlicher Mobs ist ein Koordinatenvergleich. Buchstäblich if (px < x) x--. Kein Pathfinding, kein A*, keine Hindernisvermeidung. Der Zombie passt X und Z unabhängig voneinander in Richtung des Spielers an -- er geht durch Wände, falls welche da sind.

Der Kontaktschaden beträgt 3 Herzen/Sek. p2r3 hat ihn bewusst hoch angesetzt, weil das Fehlen von Pathfinding Zombies leicht zu kiten macht.

Die Rüstungsformel ist die von vor dem Combat Update -- die denkbar einfachste:

// main.c (vereinfacht)

static uint8_t applyArmor (uint8_t damage, uint16_t armor_value) {
  // Pre-1.9-Formel: lineare Reduktion
  // Jeder Rüstungspunkt = 4% Reduktion, max 80%
  uint8_t reduction = (armor_value * 4);
  if (reduction > 80) reduction = 80;
  return damage * (100 - reduction) / 100;
}

Full Diamond = 80% Reduktion. Ein Zombie-Treffer mit 3 Herzen wird zu 0,6 Herzen. p2r3 hat diese alte Formel gewählt, weil sie in 2 Operationen berechnet wird -- keine Schwellenwerte, keine Kurven, nur ein linearer Prozentsatz.

Passive Mobs: 8 Richtungen in einer Lookup-Tabelle, Richtungswechsel alle ~40 Ticks. Das data-Feld kodiert die aktuelle Richtung in den oberen 2 Bits und den Richtungswechsel-Timer in den restlichen 6 Bits.

Mobs in Bareiron -- Zombies, Schweine, Schafe

Mob-Respawn

Mobs spawnen nicht durch Random Ticks. Sie erscheinen, wenn der Server-Tick eine neue Chunk-Grenze erreicht:

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;
  }
}

Gleiches RNG wie das Gelände, gleicher Chunk-Seed. Wenn ein Mob-Platz frei ist, ist das Spawning deterministisch.

Crafting: Keine Matrizen, nur if/else

// crafting.c, Zeilen 9-347 (vereinfacht)

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // Wenn Flag 0x80 gesetzt ist, wird der Craft-Puffer von einer Truhe verwendet
  if (player->flags & 0x80) { *count = 0; *item = 0; return; }

  // Slots zählen, erstes Item finden, Identität prüfen
  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:  /* Bretter, Barren... */
    case 2:  /* Stöcke, Scheren, Fackeln */
    case 3:  /* Schaufeln, Schwerter, Platten */
    case 4:  /* Werkbank, Stiefel */
    case 5:  /* Spitzhacken, Äxte, Helme */
    case 7:  /* Beinschienen, Komposter */
    case 8:  /* Ofen, Truhe, Brustplatte */
    case 9:  /* Volle Blöcke (Eisen, Gold, usw.) */
  }
}

Der erste Check: Wenn Flag 0x80 gesetzt ist, wird der Craft-Puffer als Truhenzeiger zweckentfremdet. Kein Crafting möglich.

Dann zählt es die gefüllten Slots, merkt sich das erste Item und prüft die Identität. Damit allein matcht es den Ofen in 4 Checks:

if (count == 8 && first == cobblestone && all_identical && center_empty)
    return furnace;

Für komplexe Formen verwendet es den Index des ersten Items und prüft die relative Position. Die Rezepte teilen sich dieselbe Matching-Funktion -- das Material bestimmt das Ergebnis.

Crafting- und Truhen-Interface in Bareiron

Die Truhen: Der berüchtigte Hack

Der Memory-Hack, von dem alle reden, in echtem Code:

// procedures.c, Zeilen 1262-1293

if (target == B_chest) {
  // Truheneintrag im Block-Array suchen
  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]);  // zeigt hinter den Truhen-Block
    break;
  }
  if (storage_ptr == NULL) return;

  // Terrible memory hack!!
  // Wir kopieren den ZEIGER in das Craft-Item-Array des Spielers
  memcpy(player->craft_items, &storage_ptr, sizeof(storage_ptr));
  player->flags |= 0x80;  // Craft sperren

  // Truhen-Interface an Client senden
  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);
  }
}

Und der Kommentar im Code: // Terrible memory hack!!1!

Genau das ist es. Es nimmt die Speicheradresse des nächsten Eintrags in block_changes[], kopiert sie in player->craft_items (ein uint16_t[9], also 18 Bytes -- genug, um einen 32-Bit-Zeiger zu speichern) und setzt das Flag, damit niemand in dieser Zeit craften kann.

Bei jedem Klick im Truhen-Inventar:

// packets.c, Zeilen 620-638

uint8_t *storage_ptr;
memcpy(&storage_ptr, player->craft_items, sizeof(storage_ptr));
// storage_ptr zeigt jetzt auf die Truhendaten
uint16_t *p_item = (uint16_t *)(storage_ptr + (slot - 41) * 3);
uint8_t *p_count = storage_ptr + (slot - 41) * 3 + 2;

Es holt den Zeiger aus dem Craft-Puffer und greift mit einem Offset auf die Slots zu. Die Truhendaten werden mit 3 Bytes pro Slot gespeichert (2 für die ID, 1 für die Menge), direkt hintereinander im Block-Array.

Truhendaten im Block-Array gespeichert -- ein Memory-Hack

Der Hunger: 5 Zeilen Genie

// main.c, Zeilen 293-305

// Spieler senden Bewegungs-Pakete mit ~20/Sek, wenn sie sich
// bewegen, viel weniger, wenn sie stillstehen. Wir korrelieren
// das mit der Aktivität, um Hunger kostenlos zu simulieren.
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;
}

Es ist wirklich das. 5 Zeilen. Jedes Bewegungs-Paket verringert die Sättigung. Wenn die Sättigung Null erreicht, sinkt der Hunger und die Sättigung wird zurückgesetzt. Sprinten (Flag 0x08) verdoppelt die Abnahme.

Null Timer, null allokierter Speicher, null dedizierte Berechnung. Ein Zähler, der auf bereits existierenden Paketen dekrementiert.

Fallschaden

Das einfachste Schadenssystem im Projekt:

// Wenn der Spieler den Boden verlässt, speichern wir sein Y
// Wenn er wieder Boden berührt, subtrahieren wir
schaden = letztes_y_am_boden - aktuelles_y;

Eine Subtraktion.

Blöcke abbauen und platzieren

Wenn du auf einen Block klickst, landet das Paket 0x28 (Player Action) im Switch. Der Handler muss bestimmen, welcher Block sich an der Position befindet, ihn entfernen und das Item ins Inventar legen:

// main.c, case 0x28 (vereinfacht)

void handlePlayerAction (int client_fd, uint8_t action, int x, uint8_t y, int z) {
  switch (action) {
    case START_DESTROY_BLOCK: {
      // Blocktyp an der angeklickten Position bestimmen
      uint8_t block = getBlockAt(x, y, z);

      if (block == B_chest) {
        openChest(client_fd, x, y, z);
        break;
      }

      // Zu block_changes hinzufügen
      addBlockChange(x, z, y, 0);  // 0 = air

      // Item dem Spieler geben (trust the client)
      addItemToPlayer(client_fd, block_to_item(block), 1);

      // Update an Client senden
      sc_blockChange(client_fd, x, y, z, 0);
      sc_ackBlockChange(client_fd, x, y, z, 0);
      break;
    }
    case PLACE_BLOCK: {
      // Blocktyp aus der Spielerhand lesen
      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 kombiniert die Terrain-Generierung UND die Spieler-Änderungen:

uint8_t getBlockAt (int x, uint8_t y, int z) {
  // Zuerst Spieler-Änderungen prüfen
  uint8_t change = getBlockChange(x, y, z);
  if (change != 0xFF) return change;

  // Sonst vom generierten Gelände lesen
  return getTerrainBlock(x, y, z);
}

Priorität für Änderungen, Fallback auf das Gelände. Null Debatte, Null Cache, Null Overhead. Unter der Haube ist getTerrainBlock = getHeightAt + die Stone/Dirt/Grass/Coal-Schichten.

Der Instant-Ofen

Das Lustigste: Der Ofen existiert nicht als Entität. Wenn du Bruchstein in den "Kochen"-Slot und Kohle in den "Brennstoff"-Slot legst, erscheint das Ergebnis sofort. Kein Timer, kein Chunk-Ticking. Es ist nur ein Inventar-Slot, der sich leert, wenn du die richtigen Items reinlegst.

Instant-Ofen -- Zutaten rein, Ergebnis sofort

Die ESP32-Schleife: Ein MC-Server in 4 KB Stack

// main.c, Zeilen 732-779

#ifdef ESP_PLATFORM

void bareiron_main (void *pvParameters) {
  main();
  vTaskDelete(NULL);
}

static void wifi_event_handler (...) {
  if (/* verbunden */) {
    xTaskCreate(bareiron_main, "bareiron", 4096, NULL, 5, NULL);
  }
}

void app_main () {
  esp_timer_early_init();
  wifi_init();
  // Der Rest wird vom Event-Handler erledigt
}
#endif

Der gesamte Server läuft in einer FreeRTOS-Aufgabe mit 4096 Bytes Stack. Das war's. Der Haupt-Thread initialisiert nur das WLAN und wartet auf eine Verbindung. Sobald verbunden, startet er bareiron_main, das die Standard-main() aufruft.

Der gesamte ESP32-spezifische Code ist durch #ifdef ESP_PLATFORM geschützt. Auf dem PC kompiliert das alles zu Standard-POSIX-Code.

Was geopfert wurde

Damit alles reinpasst, gibt es einige Vanilla-Features nicht:

  • Keine Netzwerk-Kompression -- zlib zu teuer. Der Server generiert Chunks schnell, aber sie zu senden ist der Flaschenhals.
  • Keine Random Ticks -- Bäume wachsen mit Knochenmehl oder gar nicht. Mobs spawnen an Chunk-Grenzen.
  • Keine Item-Entitäten -- abgebaute Blöcke landen direkt im Inventar. Die Animation ist rein visuell.
  • Keinerlei Inventar-Validierung -- trust the client. 64 Diamanten? OK. Ein Chunk in 1 Sekunde abgebaut? OK. Nur zwischen vertrauenswürdigen Leuten nutzbar.
  • Kein Server-Licht -- Fackeln werden nach allem anderen gesendet, der Client berechnet.
  • Keine fließenden Flüssigkeiten -- sofortiger Endzustand.

Das Endergebnis

Ryzen 5 3600: ~0,5 ms pro Chunk. ESP32-C3 für 1$: ~200 ms pro Chunk. Spielbar.

Chunk-Generierungs-Benchmark -- Ryzen vs ESP32

3+ Spieler: es ruckelt. Vergleichbar mit 2b2t zu Stoßzeiten, so der Autor.

Mehrere Spieler auf demselben Bareiron-Server

Die Philosophie

p2r3: "Ich mag einfach den Gedanken, dass dieser winzige 1$-Chip, der 0,5 Watt verbraucht, etwas so Fortschrittliches wie Minecraft zum Laufen bringen kann. Science isn't about 'why', it's about 'why not'."

Jede Zeile ist ein Tradeoff:

  • Perlin Noise → Interpolation: weniger hübsch, 200x schneller, null Speicher
  • Crafting-Matrizen → hartkodiertes Matching: hässlicher Code, null Bytes
  • zlib → nichts: schlechte Verbindung = Tod, aber spielbar
  • Validierung → Trust: null Sicherheit, null Rechenaufwand

Jede fehlende Feature ermöglicht es einem anderen, innerhalb der Hardware-Grenzen zu existieren.

Die 3 Dinge zum Mitnehmen:

  1. Interpolation + RNG -- 4 geseedete Punkte, unendliches Gelände, null Speicher, Abfragen ohne Chunk-Neugenerierung, 200 ms Generierung. Das ist der Geniestreich, der alles andere ermöglicht.
  2. Jedes Feature hat seinen Preis -- Keine Kompression, keine Random Ticks, keine Validierung. Das sind keine Versehen, das ist der Grund, warum alles in 520 KB passt.
  3. Die dreckigsten Hacks sind die cleversten -- Truhen im Block-Array per memcpy, Hunger durch Bewegungs-Pakete, Instant-Ofen. Die saubere Lösung wäre zu teuer gewesen.

Wenn dich das Projekt interessiert, alles ist auf GitHub unter GPLv3. Es ist richtig dreckiges C, und ich habe selten so viel Spaß beim Lesen von Quellcode gehabt xD

Bareiron -- сервер Minecraft, работающий на микроконтроллере за 1$

6800 строк на C, ни одного malloc, Perlin noise заменён билинейной

Введение

Ты когда-нибудь задумывался, можно ли запустить сервер Minecraft на микроконтроллере за 1 бакс?

Я -- да. И ответ -- да. Буквально.

Есть проект под названием Bareiron, автор p2r3, и это, вероятно, один из самых крутых проектов в мире Minecraft за последние годы. Речь о бинарнике, который весит 300 килобайт, 6800 строк на C, ноль внешних зависимостей, без malloc, без threading, и всё это работает на ESP32 за 1 доллар.

ESP32-C3, микроконтроллер, на котором работает сервер

Бесконечная генерация мира. Биомы. Пещеры. Крафт. Добыча. Мобы. Голод. Сундуки. Всё, что ты ждёшь от сервера в выживании.

На чипе, который потребляет 0.5 Ватт и имеет тактовую частоту 160 МГц.

Чтобы ты понимал масштаб: ванильному серверу Minecraft нужно несколько гигов RAM. У ESP32-C3 -- 520 КБ SRAM (400 доступно после загрузки). Процессоры 20 лет назад уже работали на гигагерцах -- этот едва достигает 160 МГц. Разница в чистой производительности -- примерно 20 000 раз.

p2r3 не просто написал сервер Minecraft на C -- он переизобрёл каждый кирпичик сервера, чтобы всё влезло в эти ограничения. Давай посмотрим как, открыв исходный код.

Миниатюра видео с презентацией Bareiron от p2r3

Мозг проекта: генерация мира без памяти

Самая большая проблема, когда ты хочешь сделать встраиваемый MC-сервер -- это генерация мира.

В ванильном Minecraft мир генерируется с помощью Perlin noise: несколько наложенных слоёв (октав), 6 биомных параметров (температура, влажность, континентальность, эрозия, weirdness, глубина) и целая система кэширования, чтобы не пересчитывать всё каждый раз.

Результат великолепный. Но это дорого по вычислениям и требует RAM для хранения сгенерированных чанков.

Подход Bareiron радикально иной. Вместо наложения шума он использует билинейную интерполяцию по 4 точкам, сгенерированным детерминированным RNG.

Помнишь, когда увеличиваешь маленькое пиксельное изображение и края становятся размытыми? Вот именно это.

// worldgen.c, строки 117-171 (упрощённо)

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);
  // интерполяция между 4 углами, seed через hash + biome
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

Стандартная билинейная интерполяция: 4 угла, веса по позиции, на выходе один uint8_t. CHUNK_SIZE равен 8, так что всё делается целочисленным умножением, никаких float.

p2r3 показывает это шаг за шагом в видео: сначала 4 угла чанка, каждый с высотой, заданной через RNG.

4 угла чанка, каждый посеян детерминированным RNG

Затем интерполяция между этими 4 точками создаёт непрерывную поверхность.

Применение билинейной интерполяции между 4 углами

И повторяя паттерн на всех соседних чанках, получается бескрайний ландшафт.

Финальный результат: непрерывный неровный ландшафт

Детерминированный RNG

Ключ, который делает всё это возможным -- сидирование. У каждого чанка есть 4 угла, и каждому углу нужно псевдослучайное, но воспроизводимое значение.

// worldgen.c, строки 13-22

uint32_t getChunkHash (short x, short z) {
  uint8_t buf[8];
  memcpy(buf, &x, 2);          // 16 бит координаты X
  memcpy(buf + 2, &z, 2);      // 16 бит координаты Z
  memcpy(buf + 4, &world_seed, 4);  // 32 бита глобального сида
  return splitmix64(*((uint64_t *)buf));  // хэш
}

Он упаковывает 16 бит X, 16 бит Z и 32 бита сида в буфер из 8 байт и пропускает всё через splitmix64. Результат: уникальное детерминированное значение для каждой позиции, основанное на сиде мира.

Ты врубаешься, насколько это мощно? Серверу не нужно хранить ландшафт. Он пересчитывает на лету, когда игрок приходит в новую зону, и каждый раз выдаёт один и тот же результат.

Используемый splitmix64 -- это сверхбыстрый PRNG, спроектированный для 64-битных хэшей:

// worldgen.c (упрощённо)

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 операции: сложение, xor/сдвиг, умножение, xor/сдвиг, умножение, xor/сдвиг. Никаких lookup-таблиц, никаких циклов. Он берёт буфер из 8 байт (X + Z + сид), обрабатывает как 64-битное целое и возвращает 32 бита хэша. Детерминированно, быстро, умещается в 5 строк.

Почему это не Perlin noise

p2r3 сам говорит в видео: «чем больше цифр случайного числа ты добавляешь, тем ровнее становится ландшафт, как большее количество подбрасываний монетки приближает тебя к 50/50». На практике это то, сколько битов хэша он комбинирует:

// worldgen.c, строки 51-115

// Для биома plains: 4 комбинируемых фактора → ровный ландшафт
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Для snowy plains: 2 фактора → более пересечённый
h = (hash % 5) + ((hash >> 4) % 5);

Каждый биом сам решает, сколько извлечений битов комбинировать. Чем их больше, тем стабильнее распределение -- как больше бросков монетки, приближающихся к 50/50. Чем меньше -- тем сильнее локальные вариации.

Неровный ландшафт -- мало факторов, сильные вариации

Всего с 2 факторами snowy plains даёт холмистый, почти горный ландшафт. Пики и впадины частые.

Ровный ландшафт -- множество факторов, гладкая поверхность

С 4 факторами равнины остаются плоскими и предсказуемыми. Распределение стабилизируется.

Чанк генерируется за 200 мс на ESP32 -- против неизмеримого времени на том же железе с Perlin noise, настолько он дорогой.

Убийственная деталь: запрос блока без генерации всего чанка

Ты играешь, добываешь блок. Сервер должен знать, какой предмет тебе отдать. Наивно было бы генерировать для этого весь чанк.

С билинейной интерполяцией ты запрашиваешь любую точку плоскости напрямую по координатам. Углы чанка получаются из позиции игрока, интерполяция даёт высоту на любом смещении. Горстка математических операций, никакой генерации чанка.

p2r3: «что мне нужно -- это волшебная функция, которая может сказать, какой блок находится на заданных координатах, без доступа к памяти и без вычисления дорогих шумовых карт». Именно это он и сделал.

Вот как высота превращается в конкретные блоки:

// worldgen.c (упрощённо)

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 условий. Слой grass/dirt/stone/deepslate/bedrock. Блок на поверхности зависит от биома через biome_top[] -- grass для равнин, sand для пустыни. Никаких циклов, никаких switch'ей, каскад if, проваливающийся в нужный слой.

Пещеры: ленивейшее отражение

высота_пещеры = CAVE_BASE_DEPTH - (высота_поверхности - y);

Он отражает высоту поверхности под землёй. Это напоминает большие deepslate-полости. Ноль вычислений, одна строка.

Пещеры, сгенерированные отражением рельефа поверхности

Схема отражения рельефа для генерации пещер

Руды: версия XOR

кандидат = (chunk_x ^ col_x ^ col_z) % 100;
if (кандидат < 5 && y < 16) -> diamond

XOR координат гарантирует одного кандидата на колонку. Тип зависит только от высоты. Алмазы спрятаны под самой низкой точкой пещер, чтобы копать было небесполезно.

Биомы в tile map

Каждый биом -- это круговой остров в сетке, его тип определяется паттерном, вычисленным из сида. Сетчато, предсказуемо и бесплатно.

Карта биомов в tile map -- каждый остров это отдельный биом

У каждого биома свой набор параметров, закодированных в таблицах:

// worldgen.c (упрощённо)

static const uint8_t biome_base[] = {
  [BIOME_PLAINS]  = 48,   // базовая высота: 48
  [BIOME_DESERT]  = 52,   // чуть выше
  [BIOME_FOREST]  = 50,   // посередине
  [BIOME_TAIGA]   = 46,   // немного ниже
  [BIOME_SNOWY]   = 40,   // самый низкий
};

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 извлечения → очень ровный
  [BIOME_DESERT]  = 3,   // 3 извлечения → умеренный
  [BIOME_FOREST]  = 4,   // 4 извлечения → ровный, холмистый
  [BIOME_TAIGA]   = 3,   // 3 извлечения → умеренный
  [BIOME_SNOWY]   = 2,   // 2 извлечения → очень пересечённый
};

Plains: высота 48, 4 фактора → очень плоский ландшафт, трава.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Результат: вариация макс. ±4 блока

Desert: высота 52, 3 фактора, блок поверхности = песок. Никогда ниже уровня моря.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// Результат: вариация макс. ±6 блоков, зажат на SEA_LEVEL+1

Forest: высота 50, 4 фактора как у plains, но выше база → лесистые холмы.

Taiga: высота 46, 3 фактора → умеренные вариации, холодный ландшафт.

Snowy plains: высота 40, всего 2 фактора → самый пересечённый.

h = (hash % 5) + ((hash >> 4) % 5);
// Результат: вариация макс. ±14 блоков

Каждый биом закодирован в 3 таблицах по 5 записей: базовая высота, блок поверхности, количество факторов. Когда getHeightAtFromHash получает биом, она смотрит в эти таблицы, чтобы настроить рельеф. 15 байт данных заменяют всю биомную систему Minecraft.

Детектор биома использует сид, чтобы определить, какой биом соответствует каждому чанку:

// worldgen.c (упрощённо)

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];
}

Паттерн из 16 записей, индекс, посеянный координатами чанка. Получается повторяющаяся, но визуально связная сетка. 4 строки кода заменяют всю систему биомных параметров ванильного Minecraft.

getHeightAtFromHash: сборщик ландшафта

Функция в сердце генерации комбинирует 4 угла, посеянных биомом:

// worldgen.c (упрощённо)

static uint8_t getHeightAtFromHash (int rx, int rz, short cx, short cz,
                                    uint32_t h, uint8_t biome) {
  // 4 угла, извлечённые из хэша, разный сид для каждого угла
  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);

  // Ограничение биома: пустыня никогда не под водой
  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);
  }

  // Интерполяция от 4 углов
  return interpolate(h1, h2, h3, h4, rx, rz);
}

У каждого биома есть biome_base, сдвигающая опорную высоту, а 4 угла извлекаются из хэша с разными смещениями. Пустыня принудительно задаёт минимум выше уровня моря -- одна строка ограничения, которая избегает воды без дополнительных биомных вычислений.

Деревья и кактусы: вероятностное размещение

Генерация поверхности использует тот же хэш чанка, чтобы решить, что и где сажать:

// worldgen.c (упрощённо)

static void genFoliage (uint8_t *chunk_data, short cx, short cz,
                        uint32_t hash, uint8_t biome) {
  if (biome == BIOME_DESERT) {
    // Кактус: один кандидат на чанк, хэш определяет позицию
    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 {
    // Деревья: хэш определяет, ставить ли и где
    int tree_count = (hash & 3);  // 0-3 дерева на чанк
    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 дерева на чанк для зелёных биомов, максимум 1 кактус для пустыни. Хэш чанка -- единственный источник энтропии: & 7 для позиции внутри чанка, & 3 для счётчика. Всё детерминированно, ничего не хранится.

generateChunk: сборка всего вместе

Функция, которая собирает всё воедино, чтобы произвести полный чанк размером 8×8×256 блоков:

// worldgen.c (упрощённо)

void generateChunk (uint8_t *chunk, short cx, short cz) {
  uint32_t hash = getChunkHash(cx, cz);
  uint8_t biome = getChunkBiome(cx, cz);

  // Для каждой колонки чанка (8×8 = 64)
  for (int x = 0; x < 8; x ++) {
    for (int z = 0; z < 8; z ++) {
      // Абсолютные мировые координаты
      int wx = cx * 8 + x;
      int wz = cz * 8 + z;

      // Высота колонки
      uint8_t height = getHeightAt(wx, wz);

      // Заполнить колонку снизу вверх
      for (int y = 0; y < height; y ++) {
        uint8_t block = getTerrainBlock(wx, y, wz);
        chunk[y * 64 + z * 8 + x] = block;
      }
    }
  }

  // Добавить элементы на поверхности (деревья, кактусы)
  genFoliage(chunk, cx, cz, hash, biome);
}

Вот и всё. 3 вложенных цикла: для каждой колонки найти высоту, заполнить блоки, перейти к следующей. На выходе uint8_t[16384] (8 × 8 × 256), представляющий полный чанк. Никакого кэширования, ленивой загрузки или сжатия -- чанк генерируется и отправляется напрямую клиенту.

Хранилище: статические массивы повсюду

Архитектура памяти Bareiron -- это встраиваемый C во всей красе. Никаких malloc, hash map или связных списков.

Всё в глобальных массивах фиксированного размера.

Изменения блоков

// globals.h, строки 191-196

typedef struct {
  short x;      // 2 байта -- лимит 32 000 блоков по горизонтали
  short z;      // 2 байта
  uint8_t y;    // 1 байт -- лимит 256 блоков по вертикали
  uint8_t block; // 1 байт -- лимит 256 типов блоков
} BlockChange;

20 000 записей, примерно 25 000 изменений -- эквивалент полутора полностью выкопанных чанков. Поле block со значением 0xFF отмечает свободную запись. Поиск -- линейный скан:

Схема памяти массива блоков -- 6 байт на запись

// 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;
}

Добавление изменения такое же прямое, как и поиск:

```c
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 ++;
}

Счётчик, индекс, запись. Никакой сортировки, уплотнения или управления памятью. Когда массив заполнен, новые изменения игнорируются -- ландшафт возвращается к своему сгенерированному состоянию.

Комментарий автора насчёт лимита в 256 блоков: «я пока не собираюсь реализовывать слегка поношенные полированные медные ступеньки».

Мобы: 8 байт на голову

// globals.h, строки 240-251 (pragma pack(push, 1) для устранения выравнивания)

typedef struct {
  uint8_t type;   // 25=курица, 28=корова, 95=свинья, 106=овца, 145=зомби
  short x;
  uint8_t y;      // если health=0, Y становится таймером до удаления
  short z;
  uint8_t data;   // биты 0-4: здоровье, бит 5: овца пострижена, биты 6-7: таймер паники
} MobData;

8 байт. Максимум 16 слотов. Никакого выравнивания, никаких пропусков. Байт data -- это самодельное битовое поле: 5 бит здоровья, 1 бит стрижки, 2 бита таймера паники. А когда моб умирает, поле Y становится таймером до удаления. Переиспользование памяти на уровне битов.

Игроки: плотно упакованы

Данные игроков тоже используют #pragma pack(push, 1) -- координаты в short + uint8_t, инвентари в фиксированных массивах uint16_t + uint8_t и поле flags, кодирующее одновременно перезарядку атаки, состояние спавна, крадущийся шаг, спринт, поедание, загрузку, перезарядку движения и блокировку крафта. Всё в отдельных битах.

Главный цикл: while(true) и неблокирующий ввод-вывод

Весь сервер работает в одном цикле, одном потоке, без библиотек событий.

// main.c, строки 594-720

while (true) {
  task_yield();  // даём watchdog'у ESP32 передохнуть

  // Принять новое соединение (неблокирующее)
  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;
  }

  // Тик сервера, если прошло достаточно времени
  if (get_program_time() - last_tick_time > TIME_BETWEEN_TICKS) {
    handleServerTick(time_since_last_tick);
    last_tick_time = get_program_time();
  }

  // Round-robin: один клиент, один пакет за итерацию
  client_index = (client_index + 1) % MAX_PLAYERS;
  if (clients[client_index] == -1) continue;

  // Прочитать заголовок пакета: длина + 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);
}

Один клиент обрабатывается за итерацию цикла, и читается только один пакет за раз. task_yield() в начале цикла даёт FreeRTOS idle-задаче передохнуть на ESP32 -- без этого watchdog timer перезагрузит чип.

Диспетчеризация пакетов -- это монструозный switch на 400 строк:

// main.c, строки 68-497

void handlePacket (int client_fd, int length, int packet_id, int state) {
  switch (packet_id) {
    case 0x00:  // Handshake / Status / Login в зависимости от состояния
    case 0x01:  // Status ping
    case 0x02:  // Plugin message
    case 0x03:  // Подтверждение Login/configuration
    case 0x08:  // Chat
    case 0x0B:  // Client status (respawn)
    case 0x11:  // Click container (обрабатывает сундуки)
    case 0x19:  // Interact entity
    case 0x1D..0x20:  // Movement packets (самый большой случай)
    case 0x28:  // Player action (копать/ставить)
    // ... 40+ случаев
  }
}

Никаких динамических jump-таблиц, vtable или map. Switch компилируется в статическую jump-таблицу. Идеально для встраиваемых систем.

Случай 0x1D-0x20 самый большой -- он обрабатывает обновления позиции, урон от падения, пересечение границ чанков, спавн мобов, генерацию чанков И голод. Всё в одном большом сквозном блоке.

Код сервера Bareiron -- 6800 строк на C

Серверный тик и ИИ мобов

Функция handleServerTick вызывается каждые 50 мс (20 TPS). Она обрабатывает мир, пока главный цикл занимается игроками:

// main.c (упрощённо)

void handleServerTick (uint32_t delta) {
  // Обновить каждого моба
  for (int i = 0; i < MOB_COUNT; i ++) {
    if (mobs[i].type == 0 || mobs[i].data == 0) continue;  // мёртв или пусто

    MobData *mob = &mobs[i];
    int px, pz;
    getNearestPlayer(mob->x, mob->z, &px, &pz);

    if (mob->type == MOB_ZOMBIE) {
      // Враждебный: идёт к ближайшему игроку
      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 ++;
      // Контактный урон на расстоянии 2 блоков
      if (abs(px - mob->x) <= 2 && abs(pz - mob->z) <= 2)
        damagePlayer(getNearestPlayerId(mob->x, mob->z), 3);
    } else {
      // Пассивный: 8 случайных направлений
      uint8_t dir = getMobDir(mob);
      mob->x += dir_lookup[dir][0];
      mob->z += dir_lookup[dir][1];
      // Смена направления каждые ~40 тиков
      if (mob->data >> 6 < 1) setMobDir(mob, rand() & 7);
      mob->data = (mob->data & 0x3F) | ((mob->data - 0x40) & 0xC0);
    }

    // Пробудить чанки вокруг моба
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

ИИ враждебных мобов -- это сравнение координат. Буквально if (px < x) x--. Никакого pathfinding'а, A* или обхода препятствий. Зомби независимо подстраивает X и Z к игроку -- он проходит сквозь стены, если они есть.

Контактный урон -- 3 сердца/сек. p2r3 сделал его высоким, потому что отсутствие pathfinding'а делает зомби лёгкими для kite'а.

Формула брони -- самая простая из возможных, из версии до combat update:

// main.c (упрощённо)

static uint8_t applyArmor (uint8_t damage, uint16_t armor_value) {
  // Формула до 1.9: линейное снижение
  // Каждое очко брони = 4% снижения, макс 80%
  uint8_t reduction = (armor_value * 4);
  if (reduction > 80) reduction = 80;
  return damage * (100 - reduction) / 100;
}

Полный алмаз = 80% снижения. Удар зомби на 3 сердца превращается в 0.6 сердец. p2r3 выбрал эту старую формулу, потому что она считается в 2 операции -- никаких порогов, кривых, просто линейный процент.

Пассивные мобы: 8 направлений в lookup-таблице, смена курса каждые ~40 тиков. Поле data кодирует текущее направление в старших 2 битах, а таймер смены направления -- в оставшихся 6 битах.

Мобы в Bareiron -- зомби, свиньи, овцы

Возрождение мобов

Мобы не спавнятся по случайным тикам. Они появляются, когда серверный тик обнаруживает новую границу чанка:

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;
  }
}

Тот же RNG, что и для ландшафта, тот же сид чанка. Если слот моба свободен, спавн детерминирован.

Крафт: никаких матриц, только if/else

// crafting.c, строки 9-347 (упрощённо)

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // Если флаг 0x80 поднят, буфер крафта занят сундуком
  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:  /* доски, слитки... */
    case 2:  /* палки, ножницы, факелы */
    case 3:  /* лопаты, мечи, плиты */
    case 4:  /* верстак, ботинки */
    case 5:  /* кирки, топоры, шлемы */
    case 7:  /* поножи, компостеры */
    case 8:  /* печь, сундук, нагрудник */
    case 9:  /* полные блоки (железо, золото, и т.д.) */
  }
}

Первая проверка: если поднят флаг 0x80, буфер крафта переиспользуется как указатель на сундук. Крафт невозможен.

Затем он считает заполненные слоты, запоминает первый предмет, проверяет идентичность. Только с этим печь опознаётся за 4 проверки:

if (count == 8 && first == cobblestone && all_identical && center_empty)
    return furnace;

Для сложных форм он использует индекс первого предмета и проверяет относительную позицию. Рецепты пользуются одной общей функцией сопоставления -- материал определяет результат.

Интерфейс крафта и сундука в Bareiron

Сундуки: хак в реальном коде

Хак с памятью, о котором все говорят, в реальном коде:

// procedures.c, строки 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);
  }
}

И комментарий в коде: // Terrible memory hack!!1!

Именно так. Он берёт адрес памяти следующей записи в block_changes[], копирует его в player->craft_items (который имеет тип uint16_t[9], то есть 18 байт -- достаточно для 32-битного указателя) и поднимает флаг, чтобы никто не пытался крафтить в это время.

При каждом клике в инвентаре сундука:

// packets.c, строки 620-638

uint8_t *storage_ptr;
memcpy(&storage_ptr, player->craft_items, sizeof(storage_ptr));
// storage_ptr теперь указывает на данные сундука
uint16_t *p_item = (uint16_t *)(storage_ptr + (slot - 41) * 3);
uint8_t *p_count = storage_ptr + (slot - 41) * 3 + 2;

Он извлекает указатель из буфера крафта и обращается к слотам по смещению. Данные сундука хранятся по 3 байта на слот (2 для ID, 1 для количества), прилепленные друг к другу в массиве блоков.

Данные сундука, хранящиеся в массиве блоков -- хак с памятью

Голод: 5 строк гениальности

// main.c, строки 293-305

// Игроки отправляют пакеты движения ~20/сек, когда двигаются,
// и гораздо реже, когда стоят. Мы коррелируем это
// с активностью, чтобы симулировать голод бесплатно.
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) {  // спринт
  player->saturation -= 1;
}
}

Вот буквально и всё. 5 строк. Каждый пакет движения уменьшает насыщение. Когда насыщение доходит до нуля, голод падает, и мы сбрасываем насыщение. Спринт (флаг 0x08) удваивает расход.

Никаких таймеров, никакой выделенной памяти, никаких отдельных вычислений. Счётчик, который уменьшается на пакетах, которые уже существуют.

Урон от падения

Самая простая система урона в проекте:

// Когда игрок покидает землю, сохраняем его Y
// Когда он снова касается земли, вычитаем
урон = последний_y_на_земле - текущий_y;

Одно вычитание.

Добыча и установка блоков

Когда ты кликаешь по блоку, пакет 0x28 (Player Action) попадает в switch. Обработчик должен определить, какой блок находится в позиции, убрать его и положить предмет в инвентарь:

// main.c, case 0x28 (упрощённо)

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;
      }

      // Добавить в block_changes
      addBlockChange(x, z, y, 0);  // 0 = air

      // Дать предмет игроку (доверяем клиенту)
      addItemToPlayer(client_fd, block_to_item(block), 1);

      // Отправить обновление клиенту
      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 комбинирует генерацию мира И изменения игроков:

uint8_t getBlockAt (int x, uint8_t y, int z) {
  // Сначала проверить изменения игроков
  uint8_t change = getBlockChange(x, y, z);
  if (change != 0xFF) return change;

  // Иначе прочитать из сгенерированного мира
  return getTerrainBlock(x, y, z);
}

Приоритет изменениям, fallback на ландшафт. Никаких споров, никакого кэша, никаких накладных расходов. Под капотом getTerrainBlock -- это getHeightAt + слои stone/dirt/grass/coal.

Мгновенная печь

Самое забавное: печи как сущности не существует. Если ты кладёшь булыжник в слот «плавка» и уголь в «топливо», результат появляется мгновенно. Никакого таймера, никакого тика чанков. Это просто слот инвентаря, который опустошается, когда ты кладёшь правильные предметы.

Мгновенная печь -- положи ингредиенты, результат сразу

Цикл ESP32: MC-сервер в 4 КБ стека

// main.c, строки 732-779

#ifdef ESP_PLATFORM

void bareiron_main (void *pvParameters) {
  main();
  vTaskDelete(NULL);
}

static void wifi_event_handler (...) {
  if (/* подключён */) {
    xTaskCreate(bareiron_main, "bareiron", 4096, NULL, 5, NULL);
  }
}

void app_main () {
  esp_timer_early_init();
  wifi_init();
  // Остальное обрабатывается event handler'ом
}
#endif

Весь сервер работает в задаче FreeRTOS со стеком 4096 байт. Вот и всё. Главный поток только инициализирует WiFi и ждёт подключения. После подключения он создаёт bareiron_main, которая вызывает стандартный main().

Весь ESP32-специфичный код защищён #ifdef ESP_PLATFORM. На ПК всё это компилируется как стандартный POSIX-код.

Чем пришлось пожертвовать

Чтобы всё влезло, некоторых ванильных возможностей просто нет:

  • Нет сжатия сети -- zlib слишком дорогой. Сервер генерирует чанки быстро, но отправка -- узкое место.
  • Нет случайных тиков -- деревья растут только с bone meal или никак. Мобы спавнятся на границах чанков.
  • Нет предметов-сущностей -- добытые блоки идут сразу в инвентарь. Анимация чисто визуальная.
  • Никакой проверки инвентаря -- доверяем клиенту. 64 алмаза? Ок. Чанк выкопан за 1 сек? Ок. Использовать только с доверенными людьми.
  • Нет серверного света -- факелы отправляются после всего остального, клиент вычисляет сам.
  • Нет постепенных жидкостей -- конечное состояние мгновенно.

Финальный результат

Ryzen 5 3600: ~0.5 мс на чанк. ESP32-C3 за 1$: ~200 мс на чанк. Играбельно.

Бенчмарк генерации чанков -- Ryzen против ESP32

3+ игрока: начинает тормозить. Сравнимо с 2b2t в часы пик, как говорит автор.

Несколько игроков, подключённых к одному серверу Bareiron

Философия

p2r3: «Мне просто нравится идея, что этот крошечный чип за 1 бакс, потребляющий 0.5 Ватт, может запускать нечто настолько сложное, как Minecraft. Science isn't about 'why', it's about 'why not'».

Каждая строка -- это компромисс:

  • Perlin noise → интерполяция: менее красиво, в 200 раз быстрее, ноль памяти
  • Матрицы крафта → хардкодное сопоставление: код уродливый, ноль байт
  • zlib → ничего: плохое соединение = смерть, но играбельно
  • Валидация → доверие: ноль безопасности, ноль вычислений

Каждая отсутствующая возможность позволяет другой существовать в рамках железа.

3 вещи, которые нужно запомнить:

  1. Интерполяция + RNG -- 4 посеянных точки, бесконечный мир, ноль хранилища, запрос без регенерации чанка, 200 мс генерации. Это гениальный ход, который делает всё остальное возможным.
  2. У каждой возможности есть цена -- Нет сжатия, нет случайных тиков, нет валидации. Это не забывчивость, это то, что позволяет уложиться в 520 КБ.
  3. Уродливые хаки -- самые умные -- Сундуки в массиве блоков через memcpy, голод через пакеты движения, мгновенная печь. Чистое решение было бы слишком дорогим.

Если проект тебя заинтересовал, всё на GitHub под GPLv3. Это грязный C, и я редко получал столько удовольствия от чтения исходного кода xD

Bareiron -- el servidor de Minecraft que corre en un microcontrolador de 1$

6800 líneas de C, cero malloc, Perlin noise reemplazado por

Introducción

¿Alguna vez te has preguntado si se podría hacer funcionar un servidor de Minecraft en un microcontrolador de 1 dólar?

Yo sí. Y la respuesta es sí. Literalmente.

Hay un proyecto que se llama Bareiron, firmado por p2r3, y es probablemente uno de los proyectos más fascinantes que he visto en el mundo de Minecraft en los últimos años. Hablamos de un binario que cabe en 300 kilobytes, 6800 líneas de C, cero dependencias externas, sin malloc, sin threading, y corre en una ESP32 de 1 dólar.

ESP32-C3, el microcontrolador que hace funcionar el servidor

Generación de terreno infinita. Biomas. Cuevas. Crafteo. Minería. Mobs. Hambre. Cofres. Todo lo que esperas de un servidor survival.

En un chip que consume 0.5 Watts y tiene 160 MHz de reloj.

Para que te hagas una idea: un servidor Minecraft vanilla necesita varios gigas de RAM. La ESP32-C3 tiene 520 KB de SRAM (400 disponibles después del boot). Los procesadores de hace 20 años ya corrían en gigahercios -- este llega a 160 MHz. El factor entre ambos en potencia pura es de aproximadamente 20 000.

p2r3 no escribió un servidor de Minecraft en C, reinventó cada pieza del servidor para que quepa dentro de esas limitaciones. Vamos a ver cómo, abriendo el código fuente.

Miniatura del video de presentación de Bareiron por p2r3

El cerebro del proyecto: generación de terreno sin memoria

El problema más grande cuando quieres hacer un servidor MC embebido es la generación de terreno.

En Minecraft vanilla, el mundo se genera con Perlin noise: varias capas superpuestas (octavas), 6 parámetros biomásicos (temperatura, humedad, continentalidad, erosión, weirdness, profundidad), y todo un sistema de caching para no tener que recalcular todo cada vez.

El resultado es magnífico. Pero es caro en cálculo, y ocupa RAM para almacenar los chunks generados.

El enfoque de Bareiron es radicalmente diferente. En lugar de apilar ruido, usa interpolación bilineal sobre 4 puntos generados por un RNG determinista.

¿Sabes cuando agrandas una imagen pequeña pixelada y los bordes se vuelven borrosos? Es exactamente eso.

// worldgen.c, líneas 117-171 (simplificado)

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);  // coordenadas del chunk
  int _z = floor(z / CHUNK_SIZE);
  int rx = x % CHUNK_SIZE;          // offset dentro del chunk
  int rz = z % CHUNK_SIZE;
  uint32_t hash = getChunkHash(_x, _z);
  uint8_t biome = getChunkBiome(_x, _z);
  // interpolación entre 4 esquinas seedeadas por hash + bioma
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

La interpolación bilineal estándar: 4 esquinas, pesos según la posición, un solo uint8_t de salida. CHUNK_SIZE es 8, así que se hace con multiplicaciones enteras, sin floats.

p2r3 lo muestra paso a paso en el video: primero las 4 esquinas del chunk, cada una con una altura seedeada por el RNG.

Las 4 esquinas del chunk, cada una seedeada por el RNG determinista

Luego la interpolación entre estos 4 puntos crea una superficie continua.

Aplicación de la interpolación bilineal entre las 4 esquinas

Y repitiendo el patrón en todos los chunks adyacentes, se obtiene un terreno que se extiende hasta el infinito.

Resultado final: terreno irregular continuo

El RNG determinista

La clave que hace todo esto posible es el seedeo. Cada chunk tiene 4 esquinas, y cada esquina necesita un valor pseudoaleatorio único pero reproducible.

// worldgen.c, líneas 13-22

uint32_t getChunkHash (short x, short z) {
  uint8_t buf[8];
  memcpy(buf, &x, 2);          // 16 bits de coordenada X
  memcpy(buf + 2, &z, 2);      // 16 bits de coordenada Z
  memcpy(buf + 4, &world_seed, 4);  // 32 bits de seed global
  return splitmix64(*((uint64_t *)buf));  // hash
}

Empaqueta los 16 bits de X, 16 bits de Z, y 32 bits de seed, en un buffer de 8 bytes, y lo pasa todo por splitmix64. Resultado: un valor determinista único para cada posición, basado en la seed del mundo.

¿Captas la potencia de esto? El servidor no necesita almacenar el terreno. Recalcula sobre la marcha cuando el jugador llega a una nueva zona, y da exactamente el mismo resultado cada vez.

El splitmix64 usado es un prng ultra-rápido diseñado para hashes de 64 bits:

// worldgen.c (simplificado)

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 operaciones: suma, xor/shift, multiplicación, xor/shift, multiplicación, xor/shift. Sin lookup table, sin bucle. Toma el buffer de 8 bytes (X + Z + seed), lo trata como un entero de 64 bits, y devuelve 32 bits de hash. Es determinista, rápido, y cabe en 5 líneas.

Por qué no es Perlin noise

p2r3 lo dice él mismo en el video: "cuantos más dígitos del número aleatorio añades, más regular se vuelve el terreno, como más lanzamientos de moneda te acercan a 50/50". En la práctica, es el número de bits del hash que combina:

// worldgen.c, líneas 51-115

// Para un bioma plains: 4 factores combinados → terreno regular
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Para snowy plains: 2 factores → más accidentado
h = (hash % 5) + ((hash >> 4) % 5);

Cada bioma elige cuántas extracciones de bits combina. Cuantas más, más se estabiliza la distribución -- como más lanzamientos de moneda que se acercan a 50/50. Menos, más fuertes son las variaciones locales.

Terreno irregular -- pocos factores, variaciones fuertes

Con solo 2 factores, el snowy plains produce un terreno ondulado, casi montañoso. Los picos y los valles son frecuentes.

Terreno regular -- factores múltiples, superficie lisa

Con 4 factores, las planicies se mantienen llanas y predecibles. La distribución se estabiliza.

Un chunk se genera en 200 ms en ESP32 -- frente a un tiempo no medible en el mismo hardware con Perlin noise de lo caro que es.

El detalle que mata: consultar un bloque sin generar todo el chunk

Juegas, minas un bloque. El servidor debe saber qué item darte. Ingenuamente, habría que generar todo el chunk para eso.

Con la interpolación bilineal, consultas cualquier punto del plano directamente desde las coordenadas. Las esquinas del chunk se obtienen desde la posición del jugador, la interpolación te da la altura en cualquier offset. Un puñado de operaciones matemáticas, sin generación de chunk.

p2r3: "lo que quiero es una función mágica que pueda decirme qué bloque hay en una coordenada dada, sin acceder a la memoria ni calcular mapas de ruido caros". Exactamente lo que hizo.

Así es como la altura se convierte en bloques concretos:

// worldgen.c (simplificado)

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 condiciones. Una capa de grass/dirt/stone/deepslate/bedrock. El bloque de superficie depende del bioma mediante biome_top[] -- grass para las planicies, sand para el desierto. Sin bucle, sin switch, una cascada de if que cae en la capa correcta.

Las cuevas, el mirror más vago

altitud_cueva = CAVE_BASE_DEPTH - (altura_superficie - y);

Hace mirror de la altura de la superficie bajo tierra. Se parece a las grandes cavidades de deepslate. Cero cómputo, una línea.

Cuevas generadas por mirror del terreno de superficie

Diagrama del mirror de terreno para generar cuevas

Los minerales, versión XOR

candidato = (chunk_x ^ col_x ^ col_z) % 100;
if (candidato < 5 && y < 16) -> diamond

Un XOR de coordenadas garantiza un candidato por columna. El tipo depende solo de la altitud. Los diamantes están escondidos bajo el punto más bajo de las cuevas para que minar siga siendo útil.

Los biomas en tile map

Cada bioma es una isla circular en una cuadrícula, su tipo determinado por un patrón calculado desde la seed. Cuadriculado, predecible y gratuito.

Mapa de biomas en tile map -- cada isla es un bioma diferente

Cada bioma tiene su propio conjunto de parámetros codificados en arrays:

// worldgen.c (simplificado)

static const uint8_t biome_base[] = {
  [BIOME_PLAINS]  = 48,   // altura base: 48
  [BIOME_DESERT]  = 52,   // ligeramente más alto
  [BIOME_FOREST]  = 50,   // entre ambos
  [BIOME_TAIGA]   = 46,   // un poco más bajo
  [BIOME_SNOWY]   = 40,   // el más bajo
};

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 extracciones → muy regular
  [BIOME_DESERT]  = 3,   // 3 extracciones → moderado
  [BIOME_FOREST]  = 4,   // 4 extracciones → regular, ondulado
  [BIOME_TAIGA]   = 3,   // 3 extracciones → moderado
  [BIOME_SNOWY]   = 2,   // 2 extracciones → muy accidentado
};

Plains: altura 48, 4 factores → terreno muy plano, hierba.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Resultado: variación de ±4 bloques como máximo

Desert: altura 52, 3 factores, bloque superficie = arena. Nunca bajo el nivel del mar.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// Resultado: variación de ±6 bloques como máximo, clampado a SEA_LEVEL+1

Forest: altura 50, 4 factores como plains pero base más alta → colinas boscosas.

Taiga: altura 46, 3 factores → variaciones moderadas, terreno frío.

Snowy plains: altura 40, solo 2 factores → el más accidentado.

h = (hash % 5) + ((hash >> 4) % 5);
// Resultado: variación de ±14 bloques como máximo

Cada bioma está codificado en 3 arrays de 5 entradas: altura base, bloque de superficie, número de factores. Cuando getHeightAtFromHash recibe el bioma, consulta estos arrays para ajustar el terreno. 15 bytes de datos para reemplazar todo el sistema de biomas de Minecraft.

El detector de biomas usa la seed para determinar qué bioma corresponde a cada chunk:

// worldgen.c (simplificado)

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];
}

Un patrón de 16 entradas, un índice seedeado por las coordenadas del chunk. Da una cuadrícula repetitiva pero visualmente coherente. 4 líneas de código para reemplazar todo el sistema de parámetros biomásicos de Minecraft vanilla.

getHeightAtFromHash: el ensamblador de terreno

La función en el corazón de la generación combina las 4 esquinas seedeadas por bioma:

// worldgen.c (simplificado)

static uint8_t getHeightAtFromHash (int rx, int rz, short cx, short cz,
                                    uint32_t h, uint8_t biome) {
  // 4 esquinas extraídas del hash, seed diferente por esquina
  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);

  // Restricción del bioma: desierto nunca bajo el agua
  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);
  }

  // Interpolación desde las 4 esquinas
  return interpolate(h1, h2, h3, h4, rx, rz);
}

Cada bioma tiene una biome_base que desplaza la altura de referencia, y las 4 esquinas se extraen del hash con desplazamientos diferentes. El desierto fuerza el mínimo por encima del nivel del mar -- una línea de restricción que evita el agua sin cálculo biomásico adicional.

Árboles y cactus: colocación probabilística

La generación de superficie usa el mismo hash del chunk para decidir dónde plantar:

// worldgen.c (simplificado)

static void genFoliage (uint8_t *chunk_data, short cx, short cz,
                        uint32_t hash, uint8_t biome) {
  if (biome == BIOME_DESERT) {
    // Cactus: un candidato por chunk, el hash determina la posición
    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 {
    // Árboles: el hash determina si se colocan y dónde
    int tree_count = (hash & 3);  // 0-3 árboles por 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 árboles por chunk para los biomas verdes, 1 cactus como máximo para el desierto. El hash del chunk es la única fuente de entropía -- un & 7 para la posición dentro del chunk, un & 3 para el contador. Todo es determinista, nada se almacena.

generateChunk: ensamblarlo todo

La función que junta todo para producir un chunk completo de 8×8×256 bloques:

// worldgen.c (simplificado)

void generateChunk (uint8_t *chunk, short cx, short cz) {
  uint32_t hash = getChunkHash(cx, cz);
  uint8_t biome = getChunkBiome(cx, cz);

  // Para cada columna del chunk (8×8 = 64)
  for (int x = 0; x < 8; x ++) {
    for (int z = 0; z < 8; z ++) {
      // Coordenadas mundo absolutas
      int wx = cx * 8 + x;
      int wz = cz * 8 + z;

      // Altura de la columna
      uint8_t height = getHeightAt(wx, wz);

      // Rellenar la columna de abajo arriba
      for (int y = 0; y < height; y ++) {
        uint8_t block = getTerrainBlock(wx, y, wz);
        chunk[y * 64 + z * 8 + x] = block;
      }
    }
  }

  // Añadir los elementos de superficie (árboles, cactus)
  genFoliage(chunk, cx, cz, hash, biome);
}

Eso es todo. 3 bucles anidados: para cada columna, encontrar la altura, rellenar los bloques, pasar a la siguiente. La salida es un uint8_t[16384] (8 × 8 × 256) que representa el chunk completo. Sin caching, sin lazy loading, sin compresión -- el chunk se genera y se envía directo al cliente.

El almacenamiento: arrays estáticos por todas partes

La arquitectura de memoria de Bareiron es C embebido en todo su esplendor. Sin malloc, sin hash maps, sin listas enlazadas.

Todo está en arrays globales de tamaño fijo.

Los cambios de bloques

// globals.h, líneas 191-196

typedef struct {
  short x;      // 2 bytes -- límite de 32 000 bloques horizontal
  short z;      // 2 bytes
  uint8_t y;    // 1 byte -- límite de 256 bloques vertical
  uint8_t block; // 1 byte -- límite de 256 tipos de bloques
} BlockChange;

20 000 entradas, aproximadamente 25 000 cambios -- el equivalente a un chunk y medio completamente excavado. El campo block con valor 0xFF marca una entrada libre. La búsqueda es un escaneo lineal:

Layout de memoria del array de bloques -- 6 bytes por entrada

// 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;  // saltar datos del cofre
    #endif
  }
  return 0xFF;
}

Añadir un cambio es igual de directo que la búsqueda:

```c
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 ++;
}

Un contador, un índice, una escritura. Sin ordenamiento, sin compactación, sin gestión de memoria. Cuando el array está lleno, los nuevos cambios se ignoran -- el terreno vuelve a su estado generado.

El comentario del autor sobre el límite de 256 bloques: "no pienso implementar las escaleras de cobre ligeramente patinado encerado por ahora."

Los mobs: 8 bytes por cabeza

// globals.h, líneas 240-251 (pragma pack(push, 1) para eliminar el padding)

typedef struct {
  uint8_t type;   // 25=chicken, 28=cow, 95=pig, 106=sheep, 145=zombie
  short x;
  uint8_t y;      // si health=0, Y se convierte en un timer antes de eliminar
  short z;
  uint8_t data;   // bits 0-4: health, bit 5: sheep sheared, bits 6-7: panic timer
} MobData;

8 bytes. 16 espacios como máximo. Sin alineación, sin padding. El byte data es un bitfield casero: 5 bits de vida, 1 bit de esquilado, 2 bits de timer de pánico. Y cuando un mob muere, el campo Y se convierte en un timer antes de eliminarlo. Reutilización de memoria a nivel de bit.

Los jugadores: empaquetados apretados

Los datos de jugadores usan #pragma pack(push, 1) también -- coordenadas en short + uint8_t, inventarios en arrays fijos de uint16_t + uint8_t, y un campo flags que codifica a la vez el cooldown de ataque, el estado de spawn, sneak, sprint, eat, load, movement cooldown, y el lock de crafteo. Todo eso en bits individuales.

El bucle principal: while(true) y no-bloqueante

El servidor entero corre en un bucle, un hilo, cero event library.

// main.c, líneas 594-720

while (true) {
  task_yield();  // deja respirar al watchdog en ESP32

  // Aceptar una nueva conexión (no-bloqueante)
  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;
  }

  // Tick del servidor si el tiempo ha pasado
  if (get_program_time() - last_tick_time > TIME_BETWEEN_TICKS) {
    handleServerTick(time_since_last_tick);
    last_tick_time = get_program_time();
  }

  // Round-robin: un cliente, un paquete por iteración
  client_index = (client_index + 1) % MAX_PLAYERS;
  if (clients[client_index] == -1) continue;

  // Leer el header del paquete: 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);
}

Solo un cliente se procesa por iteración del bucle, y solo un paquete se lee a la vez. El task_yield() al inicio del bucle deja respirar al FreeRTOS idle task en ESP32 -- sin eso, el watchdog timer te resetea el chip.

El dispatch de paquetes es un switch monstruoso de 400 líneas:

// main.c, líneas 68-497

void handlePacket (int client_fd, int length, int packet_id, int state) {
  switch (packet_id) {
    case 0x00:  // Handshake / Status / Login según el estado
    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 (gestiona los cofres)
    case 0x19:  // Interact entity
    case 0x1D..0x20:  // Movement packets (el caso más grande)
    case 0x28:  // Player action (dig/place)
    // ... 40+ casos
  }
}

Sin jump table dinámica, sin vtable, sin map. Un switch se compila en jump table estática. Perfecto para embebido.

El caso 0x1D-0x20 es el más grande -- gestiona las actualizaciones de posición, el daño por caída, los cruces de fronteras de chunk, el spawn de mobs, la generación de chunks, Y el hambre. Todo en un solo gran fall-through.

El código del servidor Bareiron -- 6800 líneas de C

El tick del servidor y la IA de los mobs

La función handleServerTick se llama cada 50 ms (20 TPS). Gestiona el mundo mientras el bucle principal se ocupa de los jugadores:

// main.c (simplificado)

void handleServerTick (uint32_t delta) {
  // Actualizar cada mob
  for (int i = 0; i < MOB_COUNT; i ++) {
    if (mobs[i].type == 0 || mobs[i].data == 0) continue;  // muerto o vacío

    MobData *mob = &mobs[i];
    int px, pz;
    getNearestPlayer(mob->x, mob->z, &px, &pz);

    if (mob->type == MOB_ZOMBIE) {
      // Hostil: camina hacia el jugador más cercano
      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 ++;
      // Daño por contacto a 2 bloques
      if (abs(px - mob->x) <= 2 && abs(pz - mob->z) <= 2)
        damagePlayer(getNearestPlayerId(mob->x, mob->z), 3);
    } else {
      // Pasivo: 8 direcciones aleatorias
      uint8_t dir = getMobDir(mob);
      mob->x += dir_lookup[dir][0];
      mob->z += dir_lookup[dir][1];
      // Cambio de dirección cada ~40 ticks
      if (mob->data >> 6 < 1) setMobDir(mob, rand() & 7);
      mob->data = (mob->data & 0x3F) | ((mob->data - 0x40) & 0xC0);
    }

    // Despertar los chunks alrededor del mob
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

La IA de los mobs hostiles es una comparación de coordenadas. Literalmente if (px < x) x--. Sin pathfinding, sin A*, sin obstacle avoidance. El zombie ajusta X y Z independientemente hacia el jugador -- atraviesa las paredes si las hay.

El daño por contacto es de 3 corazones/seg. p2r3 lo puso alto porque la ausencia de pathfinding hace que los zombies sean fáciles de kitar.

La fórmula de armadura es la de antes del combat update -- la más simple posible:

// main.c (simplificado)

static uint8_t applyArmor (uint8_t damage, uint16_t armor_value) {
  // Fórmula pre-1.9: reducción lineal
  // Cada punto de armadura = 4% de reducción, max 80%
  uint8_t reduction = (armor_value * 4);
  if (reduction > 80) reduction = 80;
  return damage * (100 - reduction) / 100;
}

Full diamond = 80% de reducción. Un golpe de zombie de 3 corazones se convierte en 0.6 corazones. p2r3 eligió esta vieja fórmula porque se calcula en 2 operaciones -- sin umbrales, sin curvas, solo un porcentaje lineal.

Los mobs pasivos: 8 direcciones en una lookup table, cambio de rumbo cada ~40 ticks. El campo data codifica la dirección actual en los 2 bits más altos, y el timer de cambio de dirección en los 6 bits restantes.

Mobs en Bareiron -- zombies, cerdos, ovejas

El respawn de los mobs

Los mobs no spawnean con random ticks. Aparecen cuando el tick del servidor encuentra una nueva frontera de chunk:

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;
  }
}

Mismo RNG que el terreno, misma seed del chunk. Si un espacio de mob está libre, el spawn es determinista.

El crafteo: sin matrices, con if/else

// crafting.c, líneas 9-347 (simplificado)

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // Si el flag 0x80 está activado, el buffer de crafteo está siendo usado por un cofre
  if (player->flags & 0x80) { *count = 0; *item = 0; return; }

  // Contar los slots, encontrar el primer item, verificar la identidad
  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:  /* tablones, lingotes... */
    case 2:  /* palos, tijeras, antorchas */
    case 3:  /* palas, espadas, losas */
    case 4:  /* mesa de crafteo, botas */
    case 5:  /* picos, hachas, cascos */
    case 7:  /* grebas, compostadores */
    case 8:  /* horno, cofre, peto */
    case 9:  /* bloques completos (hierro, oro, etc.) */
  }
}

Primer check: si el flag 0x80 está activado, el buffer de crafteo se recicla como puntero de cofre. No se puede craftear.

Luego, cuenta los slots llenos, anota el primer item, verifica la identidad. Con solo eso, matcheas el horno en 4 checks:

if (count == 8 && first == cobblestone && all_identical && center_empty)
    return furnace;

Para las formas complejas, usa el índice del primer item y verifica la posición relativa. Las recetas comparten una misma función de matching -- el material determina el resultado.

Interfaz de crafteo y cofre en Bareiron

Los cofres: el hack de verdad

El hack de memoria del que todo el mundo habla, en código real:

// procedures.c, líneas 1262-1293

if (target == B_chest) {
  // Buscar la entrada del cofre en el array de bloques
  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]);  // apunta después del bloque cofre
    break;
  }
  if (storage_ptr == NULL) return;

  // Terrible memory hack!!
  // Copiamos el PUNTERO en el array de items de crafteo del jugador
  memcpy(player->craft_items, &storage_ptr, sizeof(storage_ptr));
  player->flags |= 0x80;  // bloquea el crafteo

  // Enviar la interfaz del cofre al cliente
  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);
  }
}

Y el comentario en el código: // Terrible memory hack!!1!

Es exactamente eso. Toma la dirección de memoria de la entrada siguiente en block_changes[], la copia en player->craft_items (que es un uint16_t[9], o sea 18 bytes -- suficiente para almacenar un puntero de 32 bits), y activa el flag para que nadie intente craftear mientras tanto.

En cada clic en el inventario del cofre:

// packets.c, líneas 620-638

uint8_t *storage_ptr;
memcpy(&storage_ptr, player->craft_items, sizeof(storage_ptr));
// storage_ptr ahora apunta a los datos del cofre
uint16_t *p_item = (uint16_t *)(storage_ptr + (slot - 41) * 3);
uint8_t *p_count = storage_ptr + (slot - 41) * 3 + 2;

Recupera el puntero desde el buffer de crafteo, y accede a los slots con un offset. Los datos del cofre se almacenan a razón de 3 bytes por slot (2 para el ID, 1 para la cantidad), pegados unos a otros en el array de bloques.

Datos de cofre almacenados en el array de bloques -- un hack de memoria

El hambre: 5 líneas de genio

// main.c, líneas 293-305

// Los jugadores envían paquetes de movimiento a ~20/seg cuando se
// mueven, mucho menos cuando están quietos. Correlacionamos eso
// con la actividad para simular el hambre gratuitamente.
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;
}
}

Es literalmente eso. 5 líneas. Cada paquete de movimiento decrementa la saturación. Cuando la saturación llega a cero, el hambre baja y se resetea la saturación. El sprint (flag 0x08) duplica el drenaje.

Cero timer, cero memoria asignada, cero cómputo dedicado. Un contador que se decrementa con paquetes que ya existen.

El daño por caída

El sistema de daño más simple del proyecto:

// Cuando el jugador deja el suelo, almacenamos su Y
// Cuando vuelve a tocar el suelo, restamos
danio = ultima_y_en_suelo - y_actual;

Una resta.

Minar y colocar bloques

Cuando haces clic en un bloque, el paquete 0x28 (Player Action) llega al switch. El handler debe determinar qué bloque hay en la posición, retirarlo, y poner el item en el inventario:

// main.c, case 0x28 (simplificado)

void handlePlayerAction (int client_fd, uint8_t action, int x, uint8_t y, int z) {
  switch (action) {
    case START_DESTROY_BLOCK: {
      // Determinar el tipo de bloque en la posición cliqueada
      uint8_t block = getBlockAt(x, y, z);

      if (block == B_chest) {
        openChest(client_fd, x, y, z);
        break;
      }

      // Añadir a block_changes
      addBlockChange(x, z, y, 0);  // 0 = air

      // Dar el item al jugador (trust the client)
      addItemToPlayer(client_fd, block_to_item(block), 1);

      // Enviar la actualización al cliente
      sc_blockChange(client_fd, x, y, z, 0);
      sc_ackBlockChange(client_fd, x, y, z, 0);
      break;
    }
    case PLACE_BLOCK: {
      // Leer el tipo de bloque desde la mano del jugador
      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 combina la generación de terreno Y los cambios de jugadores:

uint8_t getBlockAt (int x, uint8_t y, int z) {
  // Primero verificar los cambios de jugadores
  uint8_t change = getBlockChange(x, y, z);
  if (change != 0xFF) return change;

  // Si no, leer desde el terreno generado
  return getTerrainBlock(x, y, z);
}

Prioridad a los cambios, fallback al terreno. Cero debate, cero caché, cero overhead. El getTerrainBlock bajo el capó es getHeightAt + las capas de stone/dirt/grass/coal.

El horno instantáneo

Lo más gracioso: el horno no existe como entidad. Si pones cobblestone en la ranura "cocción" y coal en "combustible", el resultado aparece inmediatamente. Sin timer, sin chunk ticking. Es solo una ranura de inventario que se vacía cuando pones los items correctos.

Horno instantáneo -- pon los ingredientes, resultado inmediato

El bucle ESP32: un servidor MC en 4 KB de stack

// main.c, líneas 732-779

#ifdef ESP_PLATFORM

void bareiron_main (void *pvParameters) {
  main();
  vTaskDelete(NULL);
}

static void wifi_event_handler (...) {
  if (/* conectado */) {
    xTaskCreate(bareiron_main, "bareiron", 4096, NULL, 5, NULL);
  }
}

void app_main () {
  esp_timer_early_init();
  wifi_init();
  // El resto lo gestiona el event handler
}
#endif

El servidor entero corre en una tarea FreeRTOS con 4096 bytes de stack. Eso es todo. El hilo principal solo inicializa el WiFi y espera una conexión. Una vez conectado, lanza bareiron_main que llama al main() estándar.

Todo el código específico de ESP32 está protegido por #ifdef ESP_PLATFORM. En PC, todo esto compila como código POSIX estándar.

Lo que se ha sacrificado

Para que todo quepa, hay características vanilla que no existen:

  • Sin compresión de red -- zlib demasiado caro. El servidor genera chunks rápido, pero enviarlos es el bottleneck.
  • Sin random ticks -- los árboles crecen con bone meal o no crecen. Los mobs spawnean en las fronteras de chunk.
  • Sin entidades item -- los bloques minados van directo al inventario. La animación es puramente visual.
  • Sin verificación de inventario -- trust the client. ¿64 diamantes? OK. ¿Un chunk minado en 1 seg? OK. Para usar entre gente de confianza.
  • Sin luz del servidor -- las antorchas se envían después de todo lo demás, el cliente calcula.
  • Sin fluidos progresivos -- estado final instantáneo.

El resultado final

Ryzen 5 3600: ~0.5 ms por chunk. ESP32-C3 de 1$: ~200 ms por chunk. Jugable.

Benchmark de generación de chunks -- Ryzen vs ESP32

3+ jugadores: va lento. Comparable a 2b2t en horas pico, según el autor.

Varios jugadores conectados al mismo servidor Bareiron

La filosofía

p2r3: "Simplemente me gusta la idea de que este chip diminuto de 1 dólar que consume 0.5 Watts pueda hacer funcionar algo tan avanzado como Minecraft. Science isn't about 'why', it's about 'why not'."

Cada línea es un tradeoff:

  • Perlin noise → interpolación: menos bonito, 200x más rápido, cero memoria
  • Matrices de crafteo → matching hardcodeado: código feo, cero bytes
  • zlib → nada: conexión mala = muerte, pero jugable
  • Validación → trust: cero seguridad, cero cómputo

Cada característica ausente permite que otra exista dentro de los límites del hardware.

Las 3 cosas para recordar:

  1. Interpolación + RNG -- 4 puntos seedeados, terreno infinito, cero almacenamiento, query sin regenerar el chunk, 200 ms de generación. Es la jugada genial que hace todo lo demás posible.
  2. Cada característica tiene un costo -- Sin compresión, sin random ticks, sin validación. No son olvidos, es lo que permite caber en 520 KB.
  3. Los hacks feos son los más inteligentes -- Cofres en el array de bloques via memcpy, hambre por paquetes de movimiento, horno instantáneo. La solución limpia habría sido demasiado cara.

Si el proyecto te interesa, todo está en GitHub bajo GPLv3. Es C bien sucio, y rara vez he disfrutado tanto leyendo un código fuente xD

Bareiron -- o servidor Minecraft que roda em um microcontrolador de 1$

6800 linhas de C, zero malloc, Perlin noise substituído por

Introdução

Você já se perguntou se daria para rodar um servidor Minecraft em um microcontrolador de 1 real?

Eu sim. E a resposta é sim. Literalmente.

Tem um projeto chamado Bareiron, assinado p2r3, e é provavelmente um dos projetos mais fascinantes que já vi no mundo Minecraft nos últimos anos. Estamos falando de um binário que cabe em 300 kilobytes, 6800 linhas de C, zero dependência externa, sem malloc, sem threading, e roda em uma ESP32 de 1 dólar.

ESP32-C3, o microcontrolador que roda o servidor

Geração de terreno infinita. Biomas. Cavernas. Craft. Mineração. Mobs. Fome. Baús. Tudo que você espera de um servidor survival.

Em um chip que consome 0.5 Watt e tem 160 MHz de clock.

Para ter uma ideia: um servidor Minecraft vanilla precisa de vários gigas de RAM. A ESP32-C3 tem 520 KB de SRAM (400 disponíveis após o boot). Os processadores de 20 anos atrás já rodavam em gigahertz -- este aqui chega no máximo a 160 MHz. O fator entre os dois em potência pura é cerca de 20 000.

p2r3 não escreveu um servidor Minecraft em C, ele reinventou cada peça do servidor para caber nessas restrições. Vamos ver como, abrindo o código fonte.

Miniatura do vídeo de apresentação do Bareiron por p2r3

O cérebro do projeto: uma geração de terreno sem memória

O maior problema quando você quer fazer um servidor MC embarcado é a geração de terreno.

No Minecraft vanilla, o mundo é gerado com Perlin noise: várias camadas sobrepostas (octaves), 6 parâmetros biomônicos (temperatura, umidade, continentalidade, erosão, weirdness, profundidade), e todo um sistema de caching para não ter que recalcular tudo toda vez.

O resultado é magnífico. Mas é caro em processamento, e usa RAM para armazenar os chunks gerados.

A abordagem do Bareiron é radicalmente diferente. Em vez de empilhar ruído, ele usa interpolação bilinear em 4 pontos gerados por um RNG determinístico.

Sabe quando você amplia uma imagem pequena pixelizada e as bordas ficam borradas? É exatamente isso.

// worldgen.c, linhas 117-171 (simplificado)

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);
  // interpolation between 4 corners seeded by hash + biome
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

A interpolação bilinear padrão: 4 cantos, pesos conforme a posição, um único uint8_t na saída. CHUNK_SIZE é 8, então tudo se resolve com multiplicações inteiras, sem float.

p2r3 mostra passo a passo no vídeo: primeiro os 4 cantos do chunk, cada um com uma altura seedada pelo RNG.

Os 4 cantos do chunk, cada um seedado pelo RNG determinístico

Depois a interpolação entre esses 4 pontos cria uma superfície contínua.

Aplicação da interpolação bilinear entre os 4 cantos

E repetindo o padrão em todos os chunks adjacentes, obtemos um terreno que se estende infinitamente.

Resultado final: terreno irregular contínuo

O RNG determinístico

A chave que torna tudo isso possível é o seeding. Cada chunk tem 4 cantos, e cada canto precisa de um valor pseudoaleatório único mas reproduzível.

// worldgen.c, linhas 13-22

uint32_t getChunkHash (short x, short z) {
  uint8_t buf[8];
  memcpy(buf, &x, 2);          // 16 bits de coordenada X
  memcpy(buf + 2, &z, 2);      // 16 bits de coordenada Z
  memcpy(buf + 4, &world_seed, 4);  // 32 bits de seed global
  return splitmix64(*((uint64_t *)buf));  // hash
}

Ele empacota os 16 bits de X, 16 bits de Z, e 32 bits de seed, em um buffer de 8 bytes, e passa tudo pelo splitmix64. Resultado: um valor determinístico único para cada posição, baseado na seed do mundo.

Saca a potência disso? O servidor não precisa armazenar o terreno. Ele recalcula na hora quando o jogador chega em uma nova área, e dá exatamente o mesmo resultado toda vez.

O splitmix64 usado é um prng ultra-rápido projetado para hashes de 64 bits:

// worldgen.c (simplificado)

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 operações: adição, xor/shift, multiplicação, xor/shift, multiplicação, xor/shift. Sem lookup table, sem loop. Ele pega o buffer de 8 bytes (X + Z + seed), trata como um inteiro de 64 bits, e retorna 32 bits de hash. É determinístico, rápido, e cabe em 5 linhas.

Por que isso não é Perlin noise

p2r3 diz ele mesmo no vídeo: "quanto mais dígitos do número aleatório você adiciona, mais regular o terreno fica, como mais lançamentos de moeda te aproximam de 50/50". Na prática, é o número de bits do hash que ele combina:

// worldgen.c, linhas 51-115

// Para um biome plains: 4 fatores combinados → terreno regular
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Para snowy plains: 2 fatores → mais acidentado
h = (hash % 5) + ((hash >> 4) % 5);

Cada bioma escolhe quantas extrações de bits combina. Quanto mais, mais a distribuição se estabiliza -- como mais lançamentos de moeda que se aproximam de 50/50. Quanto menos, mais fortes são as variações locais.

Terreno irregular -- poucos fatores, variações fortes

Com apenas 2 fatores, o snowy plains produz um terreno montanhoso, quase acidentado. Os picos e vales são frequentes.

Terreno regular -- fatores múltiplos, superfície lisa

Com 4 fatores, as planícies permanecem planas e previsíveis. A distribuição se estabiliza.

Um chunk é gerado em 200 ms na ESP32 -- contra um tempo nem mensurável no mesmo hardware com Perlin noise de tão caro que é.

O detalhe matador: consultar um bloco sem gerar o chunk inteiro

Você joga, minera um bloco. O servidor precisa saber qual item te dar. Ingenuamente, seria necessário gerar o chunk inteiro para isso.

Com a interpolação bilinear, você consulta qualquer ponto do plano diretamente a partir das coordenadas. Os cantos do chunk são obtidos a partir da posição do jogador, a interpolação te dá a altura em qualquer offset. Um punhado de operações matemáticas, sem geração de chunk.

p2r3: "o que eu quero é uma função mágica que possa me dizer qual bloco está em uma determinada coordenada, sem acessar a memória nem calcular mapas de ruído caros". Exatamente o que ele fez.

Aqui está como a altura se torna blocos concretos:

// worldgen.c (simplificado)

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 condições. Uma camada de grass/dirt/stone/deepslate/bedrock. O bloco de superfície depende do bioma via biome_top[] -- grass para planícies, sand para deserto. Sem loop, sem switch, uma cascata de if que cai na camada certa.

As cavernas, o espelho mais preguiçoso

altitude_caverna = CAVE_BASE_DEPTH - (altura_superficie - y);

Ele espelha a altura da superfície subterrânea. Isso se parece com as grandes cavidades de deepslate. Zero processamento, uma linha.

Cavernas geradas por espelhamento do terreno de superfície

Diagrama do espelhamento de terreno para gerar cavernas

Os minérios, versão XOR

candidato = (chunk_x ^ col_x ^ col_z) % 100;
if (candidato < 5 && y < 16) -> diamond

Um XOR de coordenadas garante um candidato por coluna. O tipo depende apenas da altitude. Os diamantes estão escondidos abaixo do ponto mais baixo das cavernas para que cavar continue útil.

Os biomas em tile map

Cada bioma é uma ilha circular em uma grade, seu tipo determinado por um padrão calculado a partir da seed. Gradeado, previsível e gratuito.

Mapa dos biomas em tile map -- cada ilha é um bioma diferente

Cada bioma tem seu próprio conjunto de parâmetros codificado em arrays:

// worldgen.c (simplificado)

static const uint8_t biome_base[] = {
  [BIOME_PLAINS]  = 48,   // altura base: 48
  [BIOME_DESERT]  = 52,   // ligeiramente mais alto
  [BIOME_FOREST]  = 50,   // entre os dois
  [BIOME_TAIGA]   = 46,   // um pouco mais baixo
  [BIOME_SNOWY]   = 40,   // o mais baixo
};

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 extrações → muito regular
  [BIOME_DESERT]  = 3,   // 3 extrações → moderado
  [BIOME_FOREST]  = 4,   // 4 extrações → regular, montanhoso
  [BIOME_TAIGA]   = 3,   // 3 extrações → moderado
  [BIOME_SNOWY]   = 2,   // 2 extrações → muito acidentado
};

Plains: altura 48, 4 fatores → terreno muito plano, grama.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Resultado: variação de ±4 blocos no máximo

Desert: altura 52, 3 fatores, bloco de superfície = areia. Nunca abaixo do nível do mar.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// Resultado: variação de ±6 blocos no máximo, clampado em SEA_LEVEL+1

Forest: altura 50, 4 fatores como plains mas base mais alta → colinas arborizadas.

Taiga: altura 46, 3 fatores → variações moderadas, terreno frio.

Snowy plains: altura 40, apenas 2 fatores → o mais acidentado.

h = (hash % 5) + ((hash >> 4) % 5);
// Resultado: variação de ±14 blocos no máximo

Cada bioma é codificado em 3 arrays de 5 entradas: altura base, bloco de superfície, número de fatores. Quando getHeightAtFromHash recebe o bioma, ela consulta esses arrays para ajustar o terreno. 15 bytes de dados para substituir todo o sistema de biomas do Minecraft.

O detector de bioma usa a seed para determinar qual bioma corresponde a cada chunk:

// worldgen.c (simplificado)

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];
}

Um padrão de 16 entradas, um índice seedado pelas coordenadas do chunk. Isso dá uma grade repetitiva mas visualmente coerente. 4 linhas de código para substituir todo o sistema de parâmetros biomônicos do Minecraft vanilla.

getHeightAtFromHash: o montador de terreno

A função no coração da geração combina os 4 cantos seedados por bioma:

// worldgen.c (simplificado)

static uint8_t getHeightAtFromHash (int rx, int rz, short cx, short cz,
                                    uint32_t h, uint8_t biome) {
  // 4 cantos extraídos do hash, seed diferente por canto
  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);

  // Restrição do bioma: deserto nunca abaixo da água
  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);
  }

  // Interpolação a partir dos 4 cantos
  return interpolate(h1, h2, h3, h4, rx, rz);
}

Cada bioma tem um biome_base que desloca a altura de referência, e os 4 cantos são extraídos do hash com deslocamentos diferentes. O deserto força o mínimo acima do nível do mar -- uma linha de restrição que evita água sem cálculo biomônico adicional.

Árvores e cactus: posicionamento probabilístico

A geração de superfície usa o mesmo hash do chunk para decidir onde plantar:

// worldgen.c (simplificado)

static void genFoliage (uint8_t *chunk_data, short cx, short cz,
                        uint32_t hash, uint8_t biome) {
  if (biome == BIOME_DESERT) {
    // Cactus: um candidato por chunk, hash determina a posição
    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 {
    // Árvores: hash determina se coloca e onde
    int tree_count = (hash & 3);  // 0-3 árvores por 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 árvores por chunk para biomas verdes, 1 cactus no máximo para o deserto. O hash do chunk é a única fonte de entropia -- um & 7 para a posição dentro do chunk, um & 3 para o contador. Tudo é determinístico, nada é armazenado.

generateChunk: montando tudo

A função que junta tudo para produzir um chunk completo de 8×8×256 blocos:

// worldgen.c (simplificado)

void generateChunk (uint8_t *chunk, short cx, short cz) {
  uint32_t hash = getChunkHash(cx, cz);
  uint8_t biome = getChunkBiome(cx, cz);

  // Para cada coluna do chunk (8×8 = 64)
  for (int x = 0; x < 8; x ++) {
    for (int z = 0; z < 8; z ++) {
      // Coordenadas mundo absolutas
      int wx = cx * 8 + x;
      int wz = cz * 8 + z;

      // Altura da coluna
      uint8_t height = getHeightAt(wx, wz);

      // Preencher a coluna de baixo para cima
      for (int y = 0; y < height; y ++) {
        uint8_t block = getTerrainBlock(wx, y, wz);
        chunk[y * 64 + z * 8 + x] = block;
      }
    }
  }

  // Adicionar os elementos de superfície (árvores, cactus)
  genFoliage(chunk, cx, cz, hash, biome);
}

É isso. 3 loops aninhados: para cada coluna, encontrar a altura, preencher os blocos, passar para a próxima. A saída é um uint8_t[16384] (8 × 8 × 256) que representa o chunk completo. Sem caching, sem lazy loading, sem compressão -- o chunk é gerado e enviado direto ao cliente.

O armazenamento: arrays estáticos em toda parte

A arquitetura de memória do Bareiron é C embarcado em todo seu esplendor. Sem malloc, sem hash maps, sem listas encadeadas.

Tudo está em arrays globais de tamanho fixo.

As alterações de blocos

// globals.h, linhas 191-196

typedef struct {
  short x;      // 2 bytes -- limite de 32 000 blocos horizontal
  short z;      // 2 bytes
  uint8_t y;    // 1 byte -- limite de 256 blocos vertical
  uint8_t block; // 1 byte -- limite de 256 tipos de blocos
} BlockChange;

20 000 entradas, ou cerca de 25 000 alterações -- o equivalente a um chunk e meio totalmente escavado. O campo block com valor 0xFF marca uma entrada livre. A busca é uma varredura linear:

Layout de memória do array de blocos -- 6 bytes por entrada

// 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;
}

Adicionar uma alteração é tão direto quanto a busca:

```c
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 ++;
}

Um contador, um índice, uma escrita. Sem ordenação, sem compactação, sem gerenciamento de memória. Quando o array está cheio, novas alterações são ignoradas -- o terreno retorna ao seu estado gerado.

O comentário do autor sobre o limite de 256 blocos: "não pretendo implementar escadas de cobre levemente patinadas encerradas tão cedo."

Os mobs: 8 bytes por cabeça

// globals.h, linhas 240-251 (pragma pack(push, 1) para eliminar padding)

typedef struct {
  uint8_t type;   // 25=chicken, 28=cow, 95=pig, 106=sheep, 145=zombie
  short x;
  uint8_t y;      // se health=0, Y vira um timer antes da remoção
  short z;
  uint8_t data;   // bits 0-4: health, bit 5: sheep sheared, bits 6-7: panic timer
} MobData;

8 bytes. 16 slots no máximo. Sem alinhamento, sem padding. O byte data é um bitfield caseiro: 5 bits de vida, 1 bit de tosa, 2 bits de timer de pânico. E quando um mob morre, o campo Y vira um timer antes da remoção. Reuso de memória no nível do bit.

Os jogadores: empacotados bem apertados

Os dados dos jogadores usam #pragma pack(push, 1) também -- coordenadas em short + uint8_t, inventários em arrays fixos de uint16_t + uint8_t, e um campo flags que codifica ao mesmo tempo o cooldown de ataque, estado de spawn, sneak, sprint, eat, load, movement cooldown, e o lock de craft. Tudo isso em bits individuais.

O loop principal: while(true) e não-bloqueante

O servidor inteiro roda em um loop, uma thread, zero event library.

// main.c, linhas 594-720

while (true) {
  task_yield();  // deixa o watchdog respirar na ESP32

  // Aceitar uma nova conexão (não-bloqueante)
  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;
  }

  // Tick do servidor se o tempo tiver passado
  if (get_program_time() - last_tick_time > TIME_BETWEEN_TICKS) {
    handleServerTick(time_since_last_tick);
    last_tick_time = get_program_time();
  }

  // Round-robin: um cliente, um pacote por iteração
  client_index = (client_index + 1) % MAX_PLAYERS;
  if (clients[client_index] == -1) continue;

  // Ler o cabeçalho do pacote: 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);
}

Apenas um cliente é processado por iteração do loop, e apenas um pacote é lido por vez. O task_yield() no início do loop deixa a tarefa idle do FreeRTOS respirar na ESP32 -- sem isso, o watchdog timer reseta o chip.

O dispatch dos pacotes é um switch monstruoso de 400 linhas:

// main.c, linhas 68-497

void handlePacket (int client_fd, int length, int packet_id, int state) {
  switch (packet_id) {
    case 0x00:  // Handshake / Status / Login conforme o estado
    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 (gerencia baús)
    case 0x19:  // Interact entity
    case 0x1D..0x20:  // Movement packets (o maior caso)
    case 0x28:  // Player action (cavar/colocar)
    // ... 40+ casos
  }
}

Sem jump table dinâmica, sem vtable, sem map. Um switch compila em jump table estática. Perfeito para embarcado.

O caso 0x1D-0x20 é o maior -- ele gerencia atualizações de posição, danos de queda, travessias de fronteiras de chunk, spawn de mobs, geração de chunks, E fome. Tudo em um único fall-through gigante.

O código do servidor Bareiron -- 6800 linhas de C

O tick do servidor e a IA dos mobs

A função handleServerTick é chamada a cada 50 ms (20 TPS). Ela gerencia o mundo enquanto o loop principal cuida dos jogadores:

// main.c (simplificado)

void handleServerTick (uint32_t delta) {
  // Atualizar cada mob
  for (int i = 0; i < MOB_COUNT; i ++) {
    if (mobs[i].type == 0 || mobs[i].data == 0) continue;  // morto ou vazio

    MobData *mob = &mobs[i];
    int px, pz;
    getNearestPlayer(mob->x, mob->z, &px, &pz);

    if (mob->type == MOB_ZOMBIE) {
      // Hostil: anda em direção ao jogador mais próximo
      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 ++;
      // Dano de contato a 2 blocos
      if (abs(px - mob->x) <= 2 && abs(pz - mob->z) <= 2)
        damagePlayer(getNearestPlayerId(mob->x, mob->z), 3);
    } else {
      // Passivo: 8 direções aleatórias
      uint8_t dir = getMobDir(mob);
      mob->x += dir_lookup[dir][0];
      mob->z += dir_lookup[dir][1];
      // Mudança de direção a cada ~40 ticks
      if (mob->data >> 6 < 1) setMobDir(mob, rand() & 7);
      mob->data = (mob->data & 0x3F) | ((mob->data - 0x40) & 0xC0);
    }

    // Acordar os chunks ao redor do mob
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

A IA dos mobs hostis é uma comparação de coordenadas. Literalmente if (px < x) x--. Sem pathfinding, sem A*, sem desvio de obstáculos. O zumbi ajusta X e Z independentemente em direção ao jogador -- ele atravessa paredes se houver.

O dano de contato é de 3 corações/seg. p2r3 o quis elevado porque a ausência de pathfinding torna os zombies fáceis de kitar.

A fórmula de armadura é a anterior à combat update -- a mais simples possível:

// main.c (simplificado)

static uint8_t applyArmor (uint8_t damage, uint16_t armor_value) {
  // Fórmula pré-1.9: redução linear
  // Cada ponto de armadura = 4% de redução, máx 80%
  uint8_t reduction = (armor_value * 4);
  if (reduction > 80) reduction = 80;
  return damage * (100 - reduction) / 100;
}

Full diamond = 80% de redução. Um golpe de zumbi de 3 corações vira 0.6 corações. p2r3 escolheu essa fórmula antiga porque se calcula em 2 operações -- sem limiares, sem curvas, apenas uma porcentagem linear.

Os mobs passivos: 8 direções em uma lookup table, mudança de rumo a cada ~40 ticks. O campo data codifica a direção atual nos 2 bits mais significativos, e o timer de mudança de direção nos 6 bits restantes.

Mobs no Bareiron -- zumbis, porcos, ovelhas

O respawn dos mobs

Os mobs não spawnam com random ticks. Eles aparecem quando o tick do servidor encontra uma nova fronteira de chunk:

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;
  }
}

Mesmo RNG do terreno, mesma seed do chunk. Se um slot de mob estiver livre, o spawn é determinístico.

O craft: sem matrizes, if/else

// crafting.c, linhas 9-347 (simplificado)

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // Se o flag 0x80 estiver ativo, o buffer de craft está sendo usado por um baú
  if (player->flags & 0x80) { *count = 0; *item = 0; return; }

  // Contar os slots, encontrar o primeiro item, verificar identidade
  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:  /* tábuas, lingotes... */
    case 2:  /* paus, tesouras, tochas */
    case 3:  /* pás, espadas, lajes */
    case 4:  /* bancada de craft, botas */
    case 5:  /* picaretas, machados, capacetes */
    case 7:  /* calças, composteiras */
    case 8:  /* fornalha, baú, peitoral */
    case 9:  /* blocos completos (ferro, ouro, etc.) */
  }
}

A primeira verificação: se o flag 0x80 estiver ativo, o buffer de craft é reciclado como ponteiro de baú. Sem craft possível.

Em seguida, ele conta os slots preenchidos, anota o primeiro item, verifica a identidade. Com apenas isso, você identifica a fornalha em 4 verificações:

if (count == 8 && first == cobblestone && all_identical && center_empty)
    return furnace;

Para formas complexas, ele usa o índice do primeiro item e verifica a posição relativa. As receitas compartilham uma mesma função de matching -- o material determina o resultado.

Interface de craft e baú no Bareiron

Os baús: o hack de verdade

O hack de memória que todo mundo comenta, em código real:

// procedures.c, linhas 1262-1293

if (target == B_chest) {
  // Procurar a entrada do baú no array de blocos
  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]);  // aponta após o bloco baú
    break;
  }
  if (storage_ptr == NULL) return;

  // Terrible memory hack!!
  // Copia o PONTEIRO no array de itens de craft do jogador
  memcpy(player->craft_items, &storage_ptr, sizeof(storage_ptr));
  player->flags |= 0x80;  // trava o craft

  // Enviar a interface do baú ao cliente
  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);
  }
}

E o comentário no código: // Terrible memory hack!!1!

É exatamente isso. Ele pega o endereço de memória da entrada seguinte em block_changes[], copia para player->craft_items (que é um uint16_t[9], ou seja, 18 bytes -- suficiente para armazenar um ponteiro de 32 bits), e ativa o flag para que ninguém tente craftar durante esse tempo.

Em cada clique no inventário do baú:

// packets.c, linhas 620-638

uint8_t *storage_ptr;
memcpy(&storage_ptr, player->craft_items, sizeof(storage_ptr));
// storage_ptr agora aponta para os dados do baú
uint16_t *p_item = (uint16_t *)(storage_ptr + (slot - 41) * 3);
uint8_t *p_count = storage_ptr + (slot - 41) * 3 + 2;

Ele recupera o ponteiro do buffer de craft, e acessa os slots com um offset. Os dados do baú são armazenados a 3 bytes por slot (2 para o ID, 1 para a quantidade), colados uns aos outros no array de blocos.

Dados de baú armazenados no array de blocos -- um hack de memória

A fome: 5 linhas de gênio

// main.c, linhas 293-305

// Os jogadores enviam pacotes de movimento a ~20/seg quando
// se movem, muito menos quando estão parados. Correlacionamos
// isso com a atividade para simular a fome de graça.
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;
}

É literalmente isso. 5 linhas. Cada pacote de movimento decrementa a saturação. Quando a saturação chega a zero, a fome diminui e a saturação é resetada. O sprint (flag 0x08) dobra o consumo.

Zero timer, zero memória alocada, zero processamento dedicado. Um contador que decrementa em pacotes que já existem.

Os danos de queda

O sistema de dano mais simples do projeto:

// Quando o jogador sai do chão, armazenamos seu Y
// Quando ele toca o chão novamente, subtraímos
dano = ultimo_y_no_chao - y_atual;

Uma subtração.

Minerar e colocar blocos

Quando você clica em um bloco, o pacote 0x28 (Player Action) chega no switch. O handler precisa determinar qual bloco está na posição, removê-lo, e colocar o item no inventário:

// main.c, case 0x28 (simplificado)

void handlePlayerAction (int client_fd, uint8_t action, int x, uint8_t y, int z) {
  switch (action) {
    case START_DESTROY_BLOCK: {
      // Determinar o tipo de bloco na posição clicada
      uint8_t block = getBlockAt(x, y, z);

      if (block == B_chest) {
        openChest(client_fd, x, y, z);
        break;
      }

      // Adicionar aos block_changes
      addBlockChange(x, z, y, 0);  // 0 = air

      // Dar o item ao jogador (trust the client)
      addItemToPlayer(client_fd, block_to_item(block), 1);

      // Enviar a atualização ao cliente
      sc_blockChange(client_fd, x, y, z, 0);
      sc_ackBlockChange(client_fd, x, y, z, 0);
      break;
    }
    case PLACE_BLOCK: {
      // Ler o tipo de bloco da mão do jogador
      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 combina a geração de terreno E as alterações dos jogadores:

uint8_t getBlockAt (int x, uint8_t y, int z) {
  // Primeiro verificar as alterações dos jogadores
  uint8_t change = getBlockChange(x, y, z);
  if (change != 0xFF) return change;

  // Senão, ler do terreno gerado
  return getTerrainBlock(x, y, z);
}

Prioridade às alterações, fallback no terreno. Zero debate, zero cache, zero overhead. O getTerrainBlock por baixo dos panos é getHeightAt + as camadas de stone/dirt/grass/coal.

A fornalha instantânea

O mais engraçado: a fornalha não existe como entidade. Se você colocar cobblestone no slot "cozimento" e coal no "combustível", o resultado aparece imediatamente. Sem timer, sem chunk ticking. É apenas um slot de inventário que se esvazia quando você coloca os itens certos.

Fornalha instantânea -- coloque os ingredientes, resultado imediato

O loop ESP32: um servidor MC em 4 KB de stack

// main.c, linhas 732-779

#ifdef ESP_PLATFORM

void bareiron_main (void *pvParameters) {
  main();
  vTaskDelete(NULL);
}

static void wifi_event_handler (...) {
  if (/* conectado */) {
    xTaskCreate(bareiron_main, "bareiron", 4096, NULL, 5, NULL);
  }
}

void app_main () {
  esp_timer_early_init();
  wifi_init();
  // O resto é gerenciado pelo event handler
}
#endif

O servidor inteiro roda em uma tarefa FreeRTOS com 4096 bytes de stack. É só isso. A thread main principal apenas inicializa o WiFi e espera uma conexão. Uma vez conectado, ele spawna bareiron_main que chama o main() padrão.

Todo o código específico ESP32 é protegido por #ifdef ESP_PLATFORM. No PC, tudo compila como código POSIX padrão.

O que foi sacrificado

Para que tudo isso caiba, há funcionalidades vanilla que não existem:

  • Sem compressão de rede -- zlib muito caro. O servidor gera chunks rápido, mas enviá-los é o gargalo.
  • Sem random ticks -- árvores crescem com bone meal ou não. Mobs spawnam nas fronteiras de chunk.
  • Sem entidades item -- blocos minerados vão direto para o inventário. A animação é puramente visual.
  • Nenhuma verificação de inventário -- trust the client. 64 diamantes? OK. Um chunk minerado em 1 seg? OK. Para usar entre pessoas de confiança.
  • Sem luz do servidor -- tochas são enviadas depois de todo o resto, o cliente calcula.
  • Sem fluidos progressivos -- estado final instantâneo.

O resultado final

Ryzen 5 3600: ~0.5 ms por chunk. ESP32-C3 de 1$: ~200 ms por chunk. Jogável.

Benchmark de geração de chunks -- Ryzen vs ESP32

3+ jogadores: começa a lagar. Comparável ao 2b2t nos horários de pico, segundo o autor.

Vários jogadores conectados ao mesmo servidor Bareiron

A filosofia

p2r3: "Eu só gosto da ideia de que este pequeno chip de 1 dólar que consome 0.5 Watt possa rodar algo tão avançado quanto Minecraft. Science isn't about 'why', it's about 'why not'."

Cada linha é um tradeoff:

  • Perlin noise → interpolação: menos bonito, 200x mais rápido, zero memória
  • Matrizes de craft → matching hardcoded: código feio, zero bytes
  • zlib → nada: conexão ruim = morte, mas jogável
  • Validação → trust: zero segurança, zero processamento

Cada funcionalidade ausente permite que outra exista dentro dos limites do hardware.

As 3 coisas para guardar:

  1. Interpolação + RNG -- 4 pontos seedados, terreno infinito, zero armazenamento, consulta sem regenerar o chunk, 200 ms de geração. É a jogada de gênio que torna todo o resto possível.
  2. Cada funcionalidade tem um custo -- Sem compressão, sem random ticks, sem validação. Não são esquecimentos, é o que permite caber em 520 KB.
  3. Os hacks feios são os mais inteligentes -- Baús no array de blocos via memcpy, fome por pacotes de movimento, fornalha instantânea. A solução limpa teria sido cara demais.

Se o projeto te interessa, está tudo no GitHub em GPLv3. É C bem sujo, e raramente me diverti tanto lendo um código fonte xD

Bareiron -- server Minecraft yang berjalan di mikrokontroler $1

6800 baris C, nol malloc, Perlin noise diganti dengan bilinear

Pendahuluan

Pernah kepikiran apakah kita bisa menjalankan server Minecraft di mikrokontroler $1?

Aku iya. Dan jawabannya adalah iya. Secara harfiah.

Ada proyek bernama Bareiron, buatan p2r3, dan ini mungkin salah satu proyek paling menarik yang pernah kulihat di dunia Minecraft dalam beberapa tahun terakhir. Kita bicara soal binary yang muat dalam 300 kilobyte, 6800 baris C, nol dependensi eksternal, tanpa malloc, tanpa threading, dan berjalan di ESP32 seharga $1.

ESP32-C3, mikrokontroler yang menjalankan server

Generasi terrain tak terbatas. Biome. Goa. Craft. Menambang. Mob. Rasa lapar. Peti. Semua yang kau harapkan dari server survival.

Di chip yang mengonsumsi 0.5 Watt dan memiliki clock 160 MHz.

Sebagai gambaran: server Minecraft vanilla butuh beberapa gigs RAM. ESP32-C3 cuma punya 520 KB SRAM (400 tersedia setelah boot). Prosesor 20 tahun lalu sudah berjalan di gigahertz -- yang ini mentok di 160 MHz. Faktor perbedaan daya murninya sekitar 20.000.

p2r3 tidak menulis server Minecraft di C, dia menciptakan ulang setiap bata server agar muat dalam keterbatasan itu. Mari kita lihat caranya, dengan membuka kode sumber.

Thumbnail video presentasi Bareiron oleh p2r3

Otak proyek: generasi terrain tanpa memori

Masalah terbesar saat kamu ingin membuat server MC embedded adalah generasi terrain.

Di Minecraft vanilla, dunia dibuat dengan Perlin noise: beberapa lapisan bertumpuk (oktaf), 6 parameter biomik (suhu, kelembapan, kontinentalitas, erosi, weirdness, kedalaman), dan seluruh sistem caching agar tidak perlu menghitung ulang semuanya setiap saat.

Hasilnya luar biasa. Tapi mahal secara komputasi, dan butuh RAM untuk menyimpan chunk yang sudah dibuat.

Pendekatan Bareiron sangat berbeda. Alih-alih menumpuk noise, dia menggunakan bilinear interpolation pada 4 titik yang dihasilkan oleh RNG deterministik.

Kamu tahu saat kamu memperbesar gambar kecil yang pikselasi dan pinggirannya jadi kabur? Persis seperti itu.

// worldgen.c, baris 117-171 (disederhanakan)

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);
  // interpolation between 4 corners seeded by hash + biome
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

Interpolasi bilinear standar: 4 sudut, bobot berdasarkan posisi, satu uint8_t sebagai output. CHUNK_SIZE adalah 8, jadi dilakukan dengan perkalian integer, tanpa float.

p2r3 menunjukkannya langkah demi langkah di video: pertama 4 sudut chunk, masing-masing dengan tinggi yang di-seed oleh RNG.

4 sudut chunk, masing-masing di-seed oleh RNG deterministik

Kemudian interpolasi antara 4 titik ini menciptakan permukaan yang kontinu.

Penerapan bilinear interpolation antara 4 sudut

Dan dengan mengulang pola di semua chunk yang bertetangga, kita mendapatkan terrain yang membentang tanpa batas.

Hasil akhir: terrain tidak beraturan yang kontinu

RNG deterministik

Kunci yang membuat semua ini mungkin adalah seeding. Setiap chunk punya 4 sudut, dan setiap sudut butuh nilai pseudo-acak yang unik namun reprodusibel.

// worldgen.c, baris 13-22

uint32_t getChunkHash (short x, short z) {
  uint8_t buf[8];
  memcpy(buf, &x, 2);          // 16 bit koordinat X
  memcpy(buf + 2, &z, 2);      // 16 bit koordinat Z
  memcpy(buf + 4, &world_seed, 4);  // 32 bit seed global
  return splitmix64(*((uint64_t *)buf));  // hash
}

Dia mem-packing 16 bit X, 16 bit Z, dan 32 bit seed, ke dalam buffer 8 byte, dan memasukkan semuanya ke splitmix64. Hasilnya: nilai deterministik unik untuk setiap posisi, berdasarkan seed dunia.

Kau lihat kekuatan dari ini? Server tidak perlu menyimpan terrain. Dia menghitung ulang dengan cepat saat pemain tiba di area baru, dan hasilnya persis sama setiap saat.

splitmix64 yang digunakan adalah PRNG super-cepat yang dirancang untuk hash 64 bit:

// worldgen.c (disederhanakan)

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 operasi: penambahan, xor/shift, perkalian, xor/shift, perkalian, xor/shift. Tanpa lookup table, tanpa loop. Dia mengambil buffer 8 byte (X + Z + seed), memprosesnya sebagai integer 64 bit, dan mengembalikan hash 32 bit. Ini deterministik, cepat, dan muat dalam 5 baris.

Kenapa ini bukan Perlin noise

p2r3 sendiri mengatakannya di video: "semakin banyak digit angka acak yang kau tambahkan, semakin teratur terrainnya, seperti semakin banyak lemparan koin semakin mendekati 50/50". Dalam praktiknya, ini adalah jumlah bit hash yang dia kombinasikan:

// worldgen.c, baris 51-115

// Untuk biome plains: 4 faktor dikombinasikan → terrain teratur
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Untuk snowy plains: 2 faktor → lebih berbukit
h = (hash % 5) + ((hash >> 4) % 5);

Setiap biome memilih berapa banyak ekstraksi bit yang dikombinasikan. Semakin banyak, semakin stabil distribusinya -- seperti semakin banyak lemparan koin yang mendekati 50/50. Semakin sedikit, semakin kuat variasi lokalnya.

Terrain tidak beraturan -- sedikit faktor, variasi kuat

Dengan hanya 2 faktor, snowy plains menghasilkan terrain berbukit, hampir bergunung. Puncak dan lembah sering terjadi.

Terrain teratur -- faktor banyak, permukaan halus

Dengan 4 faktor, plains tetap datar dan bisa diprediksi. Distribusinya stabil.

Sebuah chunk dibuat dalam 200 ms di ESP32 -- dibandingkan dengan waktu yang tidak terukur di hardware yang sama dengan Perlin noise karena sangat mahal.

Detail yang mematikan: query blok tanpa membuat seluruh chunk

Kamu bermain, kamu menambang blok. Server harus tahu item apa yang harus diberikan. Secara naif, perlu membuat seluruh chunk untuk itu.

Dengan bilinear interpolation, kamu bisa query titik mana pun di bidang langsung dari koordinat. Sudut chunk didapat dari posisi pemain, interpolasi memberimu tinggi di offset mana pun. Segenggam operasi matematika, tanpa generasi chunk.

p2r3: "yang aku inginkan adalah fungsi ajaib yang bisa memberitahuku blok apa yang ada di koordinat tertentu, tanpa mengakses memori atau menghitung peta noise yang mahal". Persis apa yang dia lakukan.

Berikut cara tinggi menjadi blok konkret:

// worldgen.c (disederhanakan)

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 kondisi. Satu lapisan grass/dirt/stone/deepslate/bedrock. Blok permukaan tergantung biome melalui biome_top[] -- grass untuk plains, sand untuk desert. Tanpa loop, tanpa switch, kaskade if yang jatuh ke lapisan yang tepat.

Goa, mirror paling malas

tinggi_goa = CAVE_BASE_DEPTH - (tinggi_permukaan - y);

Dia mirror tinggi permukaan di bawah tanah. Ini mirip dengan rongga besar deepslate. Nol komputasi, satu baris.

Goa dihasilkan dari mirror terrain permukaan

Diagram mirror terrain untuk menghasilkan goa

Bijih, versi XOR

kandidat = (chunk_x ^ col_x ^ col_z) % 100;
if (kandidat < 5 && y < 16) -> diamond

XOR koordinat menjamin satu kandidat per kolom. Tipe hanya tergantung pada ketinggian. Berlian disembunyikan di bawah titik terendah goa agar menambang tetap berguna.

Biome di tile map

Setiap biome adalah pulau melingkar dalam grid, tipenya ditentukan oleh pola yang dihitung dari seed. Ter-grid, bisa diprediksi, dan gratis.

Peta biome di tile map -- setiap pulau adalah biome berbeda

Setiap biome memiliki kumpulan parameternya sendiri yang dienkode dalam array:

// worldgen.c (disederhanakan)

static const uint8_t biome_base[] = {
  [BIOME_PLAINS]  = 48,   // tinggi dasar: 48
  [BIOME_DESERT]  = 52,   // sedikit lebih tinggi
  [BIOME_FOREST]  = 50,   // di antaranya
  [BIOME_TAIGA]   = 46,   // sedikit lebih rendah
  [BIOME_SNOWY]   = 40,   // yang terendah
};

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 ekstraksi → sangat teratur
  [BIOME_DESERT]  = 3,   // 3 ekstraksi → moderat
  [BIOME_FOREST]  = 4,   // 4 ekstraksi → teratur, berbukit
  [BIOME_TAIGA]   = 3,   // 3 ekstraksi → moderat
  [BIOME_SNOWY]   = 2,   // 2 ekstraksi → sangat tidak beraturan
};

Plains: tinggi 48, 4 faktor → terrain sangat datar, rumput.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Hasil: variasi maksimal ±4 blok

Desert: tinggi 52, 3 faktor, blok permukaan = pasir. Tidak pernah di bawah permukaan laut.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// Hasil: variasi maksimal ±6 blok, di-clamp ke SEA_LEVEL+1

Forest: tinggi 50, 4 faktor seperti plains tapi dasar lebih tinggi → perbukitan berhutan.

Taiga: tinggi 46, 3 faktor → variasi moderat, terrain dingin.

Snowy plains: tinggi 40, hanya 2 faktor → yang paling tidak beraturan.

h = (hash % 5) + ((hash >> 4) % 5);
// Hasil: variasi maksimal ±14 blok

Setiap biome dienkode dalam 3 array masing-masing 5 entri: tinggi dasar, blok permukaan, jumlah faktor. Saat getHeightAtFromHash menerima biome, ia melihat array ini untuk menyesuaikan terrain. 15 byte data untuk menggantikan seluruh sistem biome Minecraft.

Detektor biome menggunakan seed untuk menentukan biome mana yang cocok dengan setiap chunk:

// worldgen.c (disederhanakan)

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];
}

Pola 16 entri, index di-seed oleh koordinat chunk. Ini memberikan grid yang repetitif namun koheren secara visual. 4 baris kode untuk menggantikan seluruh sistem parameter biomik Minecraft vanilla.

getHeightAtFromHash: perakit terrain

Fungsi inti dari generasi menggabungkan 4 sudut yang di-seed berdasarkan biome:

// worldgen.c (disederhanakan)

static uint8_t getHeightAtFromHash (int rx, int rz, short cx, short cz,
                                    uint32_t h, uint8_t biome) {
  // 4 sudut diekstrak dari hash, seed berbeda per sudut
  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);

  // Constraint biome: desert tidak pernah di bawah air
  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);
  }

  // Interpolasi dari 4 sudut
  return interpolate(h1, h2, h3, h4, rx, rz);
}

Setiap biome memiliki biome_base yang menggeser tinggi referensi, dan 4 sudut diekstrak dari hash dengan offset berbeda. Desert memaksa minimal di atas permukaan laut -- satu baris constraint yang menghindari air tanpa komputasi biomik tambahan.

Pohon dan kaktus: penempatan probabilistik

Generasi permukaan menggunakan hash chunk yang sama untuk memutuskan di mana menanam:

// worldgen.c (disederhanakan)

static void genFoliage (uint8_t *chunk_data, short cx, short cz,
                        uint32_t hash, uint8_t biome) {
  if (biome == BIOME_DESERT) {
    // Kaktus: satu kandidat per chunk, hash menentukan posisi
    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 {
    // Pohon: hash menentukan apakah menempatkan dan di mana
    int tree_count = (hash & 3);  // 0-3 pohon 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 pohon per chunk untuk biome hijau, maksimal 1 kaktus untuk desert. Hash chunk adalah satu-satunya sumber entropi -- & 7 untuk posisi dalam chunk, & 3 untuk penghitung. Semuanya deterministik, tidak ada yang disimpan.

generateChunk: merakit semuanya

Fungsi yang menggabungkan semuanya untuk menghasilkan chunk lengkap 8×8×256 blok:

// worldgen.c (disederhanakan)

void generateChunk (uint8_t *chunk, short cx, short cz) {
  uint32_t hash = getChunkHash(cx, cz);
  uint8_t biome = getChunkBiome(cx, cz);

  // Untuk setiap kolom chunk (8×8 = 64)
  for (int x = 0; x < 8; x ++) {
    for (int z = 0; z < 8; z ++) {
      // Koordinat dunia absolut
      int wx = cx * 8 + x;
      int wz = cz * 8 + z;

      // Tinggi kolom
      uint8_t height = getHeightAt(wx, wz);

      // Isi kolom dari bawah ke atas
      for (int y = 0; y < height; y ++) {
        uint8_t block = getTerrainBlock(wx, y, wz);
        chunk[y * 64 + z * 8 + x] = block;
      }
    }
  }

  // Tambahkan elemen permukaan (pohon, kaktus)
  genFoliage(chunk, cx, cz, hash, biome);
}

Itu saja. 3 loop bersarang: untuk setiap kolom, cari tinggi, isi blok, lanjut ke berikutnya. Outputnya adalah uint8_t[16384] (8 × 8 × 256) yang mewakili chunk lengkap. Tanpa caching, tanpa lazy loading, tanpa kompresi -- chunk dibuat dan dikirim langsung ke client.

Penyimpanan: array statis di mana-mana

Arsitektur memori Bareiron adalah C embedded dalam segala kemegahannya. Tanpa malloc, tanpa hash map, tanpa linked list.

Semuanya dalam array global berukuran tetap.

Perubahan blok

// globals.h, baris 191-196

typedef struct {
  short x;      // 2 byte -- batas 32.000 blok horizontal
  short z;      // 2 byte
  uint8_t y;    // 1 byte -- batas 256 blok vertikal
  uint8_t block; // 1 byte -- batas 256 tipe blok
} BlockChange;

20.000 entri, sekitar 25.000 perubahan -- setara dengan satu setengah chunk yang digali seluruhnya. Field block bernilai 0xFF menandai entri bebas. Pencarian adalah scan linear:

Layout memori array blok -- 6 byte per entri

// 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;
}

Menambahkan perubahan sama langsungnya dengan pencarian:

```c
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 ++;
}

Sebuah counter, sebuah index, sebuah write. Tanpa sorting, tanpa kompaksi, tanpa manajemen memori. Saat array penuh, perubahan baru diabaikan -- terrain kembali ke keadaan awalnya.

Komentar penulis tentang batas 256 blok: "aku belum berencana mengimplementasikan tangga tembaga yang sedikit terpatinasi dipoles dalam waktu dekat."

Mob: 8 byte per kepala

// globals.h, baris 240-251 (pragma pack(push, 1) untuk menghilangkan padding)

typedef struct {
  uint8_t type;   // 25=chicken, 28=cow, 95=pig, 106=sheep, 145=zombie
  short x;
  uint8_t y;      // jika health=0, Y menjadi timer sebelum dihapus
  short z;
  uint8_t data;   // bit 0-4: health, bit 5: sheep sheared, bit 6-7: panic timer
} MobData;

8 byte. Maksimal 16 slot. Tanpa alignment, tanpa padding. Byte data adalah bitfield buatan sendiri: 5 bit health, 1 bit sheared, 2 bit panic timer. Dan saat mob mati, field Y berubah menjadi timer sebelum penghapusan. Penggunaan ulang memori di tingkat bit.

Pemain: dikemas rapat

Data pemain juga menggunakan #pragma pack(push, 1) -- koordinat dalam short + uint8_t, inventory dalam array tetap uint16_t + uint8_t, dan field flags yang mengenkode cooldown serangan, status spawn, sneak, sprint, eat, load, movement cooldown, dan lock craft. Semuanya dalam bit individual.

Loop utama: while(true) dan non-blocking

Seluruh server berjalan pada satu loop, satu thread, tanpa event library.

// main.c, baris 594-720

while (true) {
  task_yield();  // memberi napas pada watchdog di ESP32

  // Menerima koneksi baru (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;
  }

  // Tick server jika waktu sudah berlalu
  if (get_program_time() - last_tick_time > TIME_BETWEEN_TICKS) {
    handleServerTick(time_since_last_tick);
    last_tick_time = get_program_time();
  }

  // Round-robin: satu client, satu packet per iterasi
  client_index = (client_index + 1) % MAX_PLAYERS;
  if (clients[client_index] == -1) continue;

  // Baca header packet: 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);
}

Hanya satu client yang diproses per iterasi loop, dan hanya satu packet yang dibaca setiap kali. task_yield() di awal loop memberi FreeRTOS idle task untuk bernapas di ESP32 -- tanpa ini, watchdog timer akan mereset chip.

Dispatch packet adalah switch raksasa sepanjang 400 baris:

// main.c, baris 68-497

void handlePacket (int client_fd, int length, int packet_id, int state) {
  switch (packet_id) {
    case 0x00:  // Handshake / Status / Login tergantung 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 (menangani peti)
    case 0x19:  // Interact entity
    case 0x1D..0x20:  // Movement packets (kasus terbesar)
    case 0x28:  // Player action (dig/place)
    // ... 40+ kasus
  }
}

Tanpa jump table dinamis, tanpa vtable, tanpa map. Switch mengompilasi ke jump table statis. Sempurna untuk embedded.

Kasus 0x1D-0x20 adalah yang terbesar -- menangani update posisi, damage jatuh, perlintasan batas chunk, spawn mob, generasi chunk, DAN rasa lapar. Semuanya dalam satu fall-through besar.

Kode server Bareiron -- 6800 baris C

Tick server dan AI mob

Fungsi handleServerTick dipanggil setiap 50 ms (20 TPS). Ia menangani dunia sementara loop utama mengurus pemain:

// main.c (disederhanakan)

void handleServerTick (uint32_t delta) {
  // Update setiap mob
  for (int i = 0; i < MOB_COUNT; i ++) {
    if (mobs[i].type == 0 || mobs[i].data == 0) continue;  // mati atau kosong

    MobData *mob = &mobs[i];
    int px, pz;
    getNearestPlayer(mob->x, mob->z, &px, &pz);

    if (mob->type == MOB_ZOMBIE) {
      // Hostile: berjalan menuju pemain terdekat
      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 ++;
      // Damage kontak pada 2 blok
      if (abs(px - mob->x) <= 2 && abs(pz - mob->z) <= 2)
        damagePlayer(getNearestPlayerId(mob->x, mob->z), 3);
    } else {
      // Pasif: 8 arah acak
      uint8_t dir = getMobDir(mob);
      mob->x += dir_lookup[dir][0];
      mob->z += dir_lookup[dir][1];
      // Ganti arah setiap ~40 tick
      if (mob->data >> 6 < 1) setMobDir(mob, rand() & 7);
      mob->data = (mob->data & 0x3F) | ((mob->data - 0x40) & 0xC0);
    }

    // Bangunkan chunk di sekitar mob
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

AI mob hostile adalah perbandingan koordinat. Secara harfiah if (px < x) x--. Tanpa pathfinding, tanpa A*, tanpa obstacle avoidance. Zombie menyesuaikan X dan Z secara independen menuju pemain -- dia menembus tembok jika ada.

Damage kontak adalah 3 heart/detik. p2r3 sengaja membuatnya tinggi karena tanpa pathfinding zombie mudah di-kite.

Formula armor adalah yang sebelum combat update -- yang paling sederhana:

// main.c (disederhanakan)

static uint8_t applyArmor (uint8_t damage, uint16_t armor_value) {
  // Formula pra-1.9: reduksi linear
  // Setiap poin armor = 4% reduksi, maks 80%
  uint8_t reduction = (armor_value * 4);
  if (reduction > 80) reduction = 80;
  return damage * (100 - reduction) / 100;
}

Full diamond = 80% reduksi. Satu pukulan zombie 3 heart menjadi 0.6 heart. p2r3 memilih formula lama ini karena bisa dihitung dalam 2 operasi -- tanpa threshold, tanpa kurva, hanya persentase linear.

Mob pasif: 8 arah dalam lookup table, ganti arah setiap ~40 tick. Field data mengenkode arah saat ini di 2 bit tertinggi, dan timer ganti arah di 6 bit sisanya.

Mob di Bareiron -- zombie, babi, domba

Respawn mob

Mob tidak spawn dengan random tick. Mereka muncul saat server tick menemukan batas chunk baru:

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;
  }
}

RNG yang sama dengan terrain, seed chunk yang sama. Jika slot mob kosong, spawn bersifat deterministik.

Craft: tanpa matriks, pakai if/else

// crafting.c, baris 9-347 (disederhanakan)

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // Jika flag 0x80 terangkat, buffer craft digunakan oleh peti
  if (player->flags & 0x80) { *count = 0; *item = 0; return; }

  // Hitung slot, temukan item pertama, periksa identitas
  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, ingot... */
    case 2:  /* stick, shears, torch */
    case 3:  /* shovel, sword, slab */
    case 4:  /* crafting table, boots */
    case 5:  /* pickaxe, axe, helmet */
    case 7:  /* leggings, composter */
    case 8:  /* furnace, chest, chestplate */
    case 9:  /* blok penuh (iron, gold, dll.) */
  }
}

Pertama cek: jika flag 0x80 terangkat, buffer craft didaur ulang menjadi pointer peti. Tidak bisa craft.

Kemudian, hitung slot yang terisi, catat item pertama, periksa identitas. Dengan itu saja, kamu bisa mencocokkan furnace dalam 4 cek:

if (count == 8 && first == cobblestone && all_identical && center_empty)
    return furnace;

Untuk bentuk kompleks, dia menggunakan index item pertama dan memeriksa posisi relatif. Resep berbagi fungsi matching yang sama -- material menentukan hasil.

Antarmuka craft dan peti di Bareiron

Peti: hack yang sesungguhnya

Hack memori yang semua orang bicarakan, dalam kode asli:

// procedures.c, baris 1262-1293

if (target == B_chest) {
  // Cari entri peti di array blok
  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]);  // pointing setelah blok peti
    break;
  }
  if (storage_ptr == NULL) return;

  // Terrible memory hack!!
  // Salin POINTER ke array item craft pemain
  memcpy(player->craft_items, &storage_ptr, sizeof(storage_ptr));
  player->flags |= 0x80;  // lock craft

  // Kirim antarmuka peti ke 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);
  }
}

Dan komentar di kode: // Terrible memory hack!!1!

Persis itu. Dia mengambil alamat memori entri berikutnya di block_changes[], menyalinnya ke player->craft_items (yang berupa uint16_t[9], jadi 18 byte -- cukup untuk menyimpan pointer 32 bit), dan mengangkat flag agar tidak ada yang mencoba craft selama ini.

Setiap klik di inventory peti:

// packets.c, baris 620-638

uint8_t *storage_ptr;
memcpy(&storage_ptr, player->craft_items, sizeof(storage_ptr));
// storage_ptr sekarang menunjuk ke data peti
uint16_t *p_item = (uint16_t *)(storage_ptr + (slot - 41) * 3);
uint8_t *p_count = storage_ptr + (slot - 41) * 3 + 2;

Dia mengambil pointer dari buffer craft, dan mengakses slot dengan offset. Data peti disimpan dengan 3 byte per slot (2 untuk ID, 1 untuk jumlah), ditempel satu sama lain di array blok.

Data peti disimpan di array blok -- hack memori

Rasa lapar: 5 baris jenius

// main.c, baris 293-305

// Pemain mengirim packet movement ~20/detik saat bergerak,
// jauh lebih sedikit saat diam. Kita korelasikan ini
// dengan aktivitas untuk mensimulasikan rasa lapar secara gratis.
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;
}
}

Persis itu. 5 baris. Setiap packet movement mengurangi saturation. Saat saturation mencapai nol, hunger turun dan saturation di-reset. Sprint (flag 0x08) menggandakan drain.

Nol timer, nol memori dialokasikan, nol compute khusus. Sebuah counter yang berkurang pada packet yang sudah ada.

Damage jatuh

Sistem damage paling sederhana di proyek ini:

// Saat pemain meninggalkan tanah, simpan Y-nya
// Saat dia menyentuh tanah lagi, kurangi
damage = last_y_on_ground - current_y;

Satu pengurangan.

Menambang dan menempatkan blok

Saat kamu klik blok, packet 0x28 (Player Action) mendarat di switch. Handler harus menentukan blok apa yang ada di posisi itu, menghapusnya, dan menaruh item di inventory:

// main.c, case 0x28 (disederhanakan)

void handlePlayerAction (int client_fd, uint8_t action, int x, uint8_t y, int z) {
  switch (action) {
    case START_DESTROY_BLOCK: {
      // Tentukan tipe blok di posisi yang diklik
      uint8_t block = getBlockAt(x, y, z);

      if (block == B_chest) {
        openChest(client_fd, x, y, z);
        break;
      }

      // Tambahkan ke block_changes
      addBlockChange(x, z, y, 0);  // 0 = air

      // Berikan item ke pemain (trust the client)
      addItemToPlayer(client_fd, block_to_item(block), 1);

      // Kirim update ke client
      sc_blockChange(client_fd, x, y, z, 0);
      sc_ackBlockChange(client_fd, x, y, z, 0);
      break;
    }
    case PLACE_BLOCK: {
      // Baca tipe blok dari tangan pemain
      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 menggabungkan generasi terrain DAN perubahan pemain:

uint8_t getBlockAt (int x, uint8_t y, int z) {
  // Pertama periksa perubahan pemain
  uint8_t change = getBlockChange(x, y, z);
  if (change != 0xFF) return change;

  // Jika tidak, baca dari terrain yang dihasilkan
  return getTerrainBlock(x, y, z);
}

Prioritas pada perubahan, fallback ke terrain. Nol debat, nol cache, nol overhead. getTerrainBlock di balik layar adalah getHeightAt + lapisan stone/dirt/grass/coal.

Furnace instan

Yang paling lucu: furnace tidak ada sebagai entitas. Jika kamu menaruh cobblestone di slot "cooking" dan coal di "fuel", hasilnya muncul seketika. Tanpa timer, tanpa chunk ticking. Hanya slot inventory yang kosong saat kamu menaruh item yang tepat.

Furnace instan -- taruh bahan, hasil langsung

Loop ESP32: server MC dalam 4 KB stack

// main.c, baris 732-779

#ifdef ESP_PLATFORM

void bareiron_main (void *pvParameters) {
  main();
  vTaskDelete(NULL);
}

static void wifi_event_handler (...) {
  if (/* terhubung */) {
    xTaskCreate(bareiron_main, "bareiron", 4096, NULL, 5, NULL);
  }
}

void app_main () {
  esp_timer_early_init();
  wifi_init();
  // Sisanya ditangani oleh event handler
}
#endif

Seluruh server berjalan dalam satu task FreeRTOS dengan 4096 byte stack. Itu saja. Thread main utama hanya menginisialisasi WiFi dan menunggu koneksi. Setelah terhubung, dia spawn bareiron_main yang memanggil main() standar.

Semua kode spesifik ESP32 dilindungi oleh #ifdef ESP_PLATFORM. Di PC, semua ini dikompilasi sebagai kode POSIX standar.

Yang dikorbankan

Agar semua ini muat, ada fitur vanilla yang tidak ada:

  • Tidak ada kompresi jaringan -- zlib terlalu mahal. Server menghasilkan chunk cepat, tapi mengirimkannya adalah bottleneck.
  • Tidak ada random ticks -- pohon tumbuh dengan bone meal atau tidak. Mob spawn di batas chunk.
  • Tidak ada entity item -- blok yang ditambang langsung masuk inventory. Animasi murni visual.
  • Tidak ada verifikasi inventory -- trust the client. 64 diamond? OK. Chunk ditambang dalam 1 detik? OK. Gunakan antar orang yang saling percaya.
  • Tidak ada cahaya server -- torch dikirim setelah semuanya, client yang menghitung.
  • Tidak ada fluida progresif -- status akhir instan.

Hasil akhir

Ryzen 5 3600: ~0.5 ms per chunk. ESP32-C3 $1: ~200 ms per chunk. Playable.

Benchmark generasi chunk -- Ryzen vs ESP32

3+ pemain: mulai lag. Sebanding dengan 2b2t di jam sibuk, kata penulis.

Beberapa pemain terhubung ke server Bareiron yang sama

Filosofi

p2r3: "Aku cuma suka ide bahwa chip mungil seharga $1 yang mengonsumsi 0.5 Watt ini bisa menjalankan sesuatu secanggih Minecraft. Science isn't about 'why', it's about 'why not'."

Setiap baris adalah tradeoff:

  • Perlin noise → interpolasi: kurang cantik, 200x lebih cepat, nol memori
  • Matriks craft → matching hardcoded: kode kotor, nol byte
  • zlib → tidak ada: koneksi jelek = mati, tapi playable
  • Validasi → trust: nol keamanan, nol compute

Setiap fitur yang tidak ada memungkinkan fitur lain untuk eksis dalam batasan hardware.

3 hal yang perlu diingat:

  1. Interpolasi + RNG -- 4 titik di-seed, terrain tak terbatas, nol penyimpanan, query tanpa regenerasi chunk, 200 ms generasi. Ini adalah langkah jenius yang membuat segalanya mungkin.
  2. Setiap fitur ada biayanya -- Tanpa kompresi, tanpa random ticks, tanpa validasi. Ini bukan kelupaan, ini yang memungkinkan muat dalam 520 KB.
  3. Hack kotor adalah yang paling cerdas -- Peti di array blok via memcpy, lapar via packet movement, furnace instan. Solusi bersih akan terlalu mahal.

Jika proyek ini menarik minatmu, semuanya ada di GitHub dalam GPLv3. Ini C yang sangat kotor, dan jarang sekali aku menikmati membaca kode sumber sebanyak ini xD

Bareiron -- वह Minecraft सर्वर जो 1$ के माइक्रोकंट्रोलर पर चलता है

C की 6800 लाइनें, शून्य malloc, bilinear interpolation से बदला गया Perlin noise,

परिचय

क्या तुमने कभी सोचा है कि क्या 1 रुपए के माइक्रोकंट्रोलर पर Minecraft सर्वर चलाया जा सकता है?

मैंने सोचा। और जवाब है हाँ। सचमुच।

एक प्रोजेक्ट है जिसका नाम है Bareiron, p2r3 द्वारा, और यह शायद Minecraft की दुनिया में पिछले कुछ वर्षों में देखी गई सबसे आकर्षक परियोजनाओं में से एक है। हम बात कर रहे हैं एक बाइनरी की जो 300 किलोबाइट में समाती है, C की 6800 लाइनें, शून्य बाहरी निर्भरता, कोई malloc नहीं, कोई threading नहीं, और यह 1 डॉलर की ESP32 पर चलती है।

ESP32-C3, वह माइक्रोकंट्रोलर जो सर्वर चलाता है

अनंत terrain जनरेशन। Biomes। गुफाएँ। Crafting। Mining। Mobs। भूख। Chests। वह सब कुछ जो तुम एक survival सर्वर से उम्मीद करते हो।

एक चिप पर जो 0.5 Watt खपत करती है और जिसकी clock 160 MHz है।

परिप्रेक्ष्य देने के लिए: एक vanilla Minecraft सर्वर को कई गीगाबाइट RAM चाहिए। ESP32-C3 के पास 520 KB SRAM है (बूट के बाद 400 उपलब्ध)। 20 साल पहले के प्रोसेसर पहले से ही गीगाहर्ट्ज़ पर चल रहे थे -- यह 160 MHz पर अटका है। शुद्ध शक्ति में दोनों के बीच का कारक लगभग 20,000 है।

p2r3 ने C में Minecraft सर्वर नहीं लिखा, उन्होंने सर्वर के हर घटक को फिर से आविष्कार किया ताकि वह इन सीमाओं में समा सके। आइए देखें कैसे, सोर्स कोड खोलकर।

p2r3 द्वारा Bareiron प्रस्तुति वीडियो का थंबनेल

प्रोजेक्ट का दिमाग: बिना मेमोरी के terrain जनरेशन

सबसे बड़ी समस्या जब तुम एक एम्बेडेड MC सर्वर बनाना चाहते हो, वह है terrain जनरेशन।

Minecraft vanilla में, दुनिया Perlin noise से उत्पन्न होती है: कई सुपरइम्पोज़्ड परतें (octaves), 6 बायोमिक पैरामीटर (तापमान, नमी, महाद्वीपीयता, अपरदन, weirdness, गहराई), और कैशिंग की एक पूरी प्रणाली ताकि हर बार सब कुछ पुनर्गणना न करना पड़े।

परिणाम शानदार है। लेकिन यह गणना में भारी है, और उत्पन्न chunks को संग्रहीत करने के लिए RAM लेता है।

Bareiron का दृष्टिकोण मौलिक रूप से भिन्न है। शोर को स्टैक करने के बजाय, यह एक नियतिवादी RNG द्वारा उत्पन्न 4 बिंदुओं पर bilinear interpolation का उपयोग करता है।

तुम्हें पता है जब तुम एक छोटी पिक्सेलेटेड छवि को बड़ा करते हो और किनारे धुंधले हो जाते हैं? बिल्कुल वैसा ही।

// worldgen.c, लाइनें 117-171 (सरलीकृत)

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);
  // interpolation between 4 corners seeded by hash + biome
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

मानक bilinear interpolation: 4 कोने, स्थिति के अनुसार भार, एक एकल uint8_t आउटपुट। CHUNK_SIZE 8 है, इसलिए यह पूर्णांक गुणन में होता है, कोई float नहीं।

p2r3 इसे वीडियो में चरण दर चरण दिखाते हैं: पहले chunk के 4 कोने, प्रत्येक की ऊँचाई RNG द्वारा seed की गई।

chunk के 4 कोने, प्रत्येक नियतिवादी RNG द्वारा seed किया गया

फिर इन 4 बिंदुओं के बीच interpolation एक सतत सतह बनाता है।

4 कोनों के बीच bilinear interpolation का अनुप्रयोग

और पैटर्न को सभी आसन्न chunks पर दोहराकर, हमें एक ऐसा terrain मिलता है जो अनंत तक फैलता है।

अंतिम परिणाम: सतत अनियमित terrain

नियतिवादी RNG

वह कुंजी जो यह सब संभव बनाती है, वह है seeding। प्रत्येक chunk के 4 कोने होते हैं, और प्रत्येक कोने को एक अद्वितीय लेकिन पुनरुत्पादनीय छद्म-यादृच्छिक मान की आवश्यकता होती है।

// worldgen.c, लाइनें 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
}

यह X के 16 bits, Z के 16 bits, और seed के 32 bits को 8 बाइट के बफर में पैक करता है, और पूरे को splitmix64 में डालता है। परिणाम: दुनिया की seed के आधार पर प्रत्येक स्थिति के लिए एक अद्वितीय नियतिवादी मान।

समझ में आया कि यह कितना शक्तिशाली है? सर्वर को terrain संग्रहीत करने की आवश्यकता नहीं है। जब खिलाड़ी किसी नए क्षेत्र में आता है तो यह मौके पर पुनर्गणना करता है, और हर बार बिल्कुल वही परिणाम देता है।

उपयोग किया गया splitmix64 एक अति-त्वरित PRNG है जो 64-bit हैश के लिए डिज़ाइन किया गया है:

// worldgen.c (सरलीकृत)

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 ऑपरेशन: जोड़, xor/shift, गुणन, xor/shift, गुणन, xor/shift। कोई lookup table नहीं, कोई लूप नहीं। यह 8 बाइट का बफर (X + Z + seed) लेता है, इसे 64-bit पूर्णांक के रूप में मानता है, और 32 bits हैश लौटाता है। यह नियतिवादी, तेज़ है, और 5 लाइनों में समाता है।

यह Perlin noise क्यों नहीं है

p2r3 स्वयं वीडियो में कहते हैं: "तुम यादृच्छिक संख्या में जितने अधिक digits जोड़ते हो, terrain उतना ही नियमित होता जाता है, जैसे अधिक सिक्का उछालने से तुम 50/50 के करीब पहुँचते हो"। व्यवहार में, यह हैश bits की संख्या है जिसे वह जोड़ता है:

// worldgen.c, लाइनें 51-115

// For a plains biome: 4 factors combined → regular terrain
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// For snowy plains: 2 factors → more rugged
h = (hash % 5) + ((hash >> 4) % 5);

प्रत्येक biome चुनता है कि वह कितने bit निष्कर्षण जोड़ता है। जितने अधिक होंगे, वितरण उतना ही स्थिर होगा -- जैसे अधिक सिक्का उछालने से 50/50 के करीब पहुँचना। जितने कम होंगे, स्थानीय विविधताएँ उतनी ही मजबूत होंगी।

अनियमित terrain -- कम कारक, मजबूत विविधताएँ

केवल 2 कारकों के साथ, snowy plains एक पहाड़ी, लगभग पर्वतीय terrain उत्पन्न करता है। चोटियाँ और गड्ढे अक्सर होते हैं।

नियमित terrain -- कई कारक, चिकनी सतह

4 कारकों के साथ, plains सपाट और पूर्वानुमेय रहते हैं। वितरण स्थिर हो जाता है।

एक chunk ESP32 पर 200 ms में उत्पन्न होता है -- उसी हार्डवेयर पर Perlin noise के साथ अमापनीय समय की तुलना में, यह इतना महँगा है।

वह विवरण जो कमाल का है: पूरा chunk उत्पन्न किए बिना एक ब्लॉक को क्वेरी करना

तुम खेलते हो, तुम एक ब्लॉक खोदते हो। सर्वर को पता होना चाहिए कि तुम्हें कौन सी item देनी है। सामान्यतः, इसके लिए पूरा chunk उत्पन्न करना होगा।

bilinear interpolation के साथ, तुम निर्देशांकों से सीधे समतल के किसी भी बिंदु को क्वेरी कर सकते हो। chunk के कोने खिलाड़ी की स्थिति से प्राप्त होते हैं, interpolation तुम्हें किसी भी offset पर ऊँचाई देता है। गणितीय संचालन की एक मुट्ठी, कोई chunk जनरेशन नहीं।

p2r3: "मैं एक जादुई फ़ंक्शन चाहता हूँ जो मुझे बता सके कि किसी दिए गए निर्देशांक पर कौन सा ब्लॉक है, बिना मेमोरी एक्सेस किए या महँगी noise मैप की गणना किए"। उन्होंने ठीक यही किया।

यहाँ बताया गया है कि ऊँचाई ठोस ब्लॉकों में कैसे बदलती है:

// worldgen.c (सरलीकृत)

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 शर्तें। grass/dirt/stone/deepslate/bedrock की एक परत। सतह का ब्लॉक biome_top[] के माध्यम से biome पर निर्भर करता है -- plains के लिए grass, रेगिस्तान के लिए sand। कोई लूप नहीं, कोई switch नहीं, if का एक झरना जो सही परत में गिरता है।

गुफाएँ, सबसे आलसी mirror

cave_altitude = CAVE_BASE_DEPTH - (surface_height - y);

यह ज़मीन के नीचे सतह की ऊँचाई को mirror करता है। यह deepslate की बड़ी गुहाओं जैसा दिखता है। शून्य गणना, एक लाइन।

सतही terrain के mirror द्वारा उत्पन्न गुफाएँ

गुफाएँ उत्पन्न करने के लिए terrain mirror का आरेख

अयस्क, XOR संस्करण

candidate = (chunk_x ^ col_x ^ col_z) % 100;
if (candidate < 5 && y < 16) -> diamond

निर्देशांकों का XOR प्रति स्तंभ एक उम्मीदवार की गारंटी देता है। प्रकार केवल ऊँचाई पर निर्भर करता है। हीरे गुफाओं के सबसे निचले बिंदु के नीचे छिपे होते हैं ताकि खुदाई उपयोगी बनी रहे।

Tile map में Biomes

प्रत्येक biome एक ग्रिड में एक गोलाकार द्वीप है, इसका प्रकार seed से गणना किए गए पैटर्न द्वारा निर्धारित होता है। ग्रिडेड, पूर्वानुमेय, और मुफ्त।

Tile map में biomes का नक्शा -- प्रत्येक द्वीप एक अलग biome है

प्रत्येक biome के अपने पैरामीटर सेट होते हैं जो सारणियों में एन्कोडेड होते हैं:

// worldgen.c (सरलीकृत)

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 regular
  [BIOME_DESERT]  = 3,   // 3 extractions → moderate
  [BIOME_FOREST]  = 4,   // 4 extractions → regular, rolling
  [BIOME_TAIGA]   = 3,   // 3 extractions → moderate
  [BIOME_SNOWY]   = 2,   // 2 extractions → very rugged
};

Plains: ऊँचाई 48, 4 कारक → बहुत सपाट terrain, घास।

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Result: max ±4 blocks variation

Desert: ऊँचाई 52, 3 कारक, सतह ब्लॉक = sand। कभी समुद्र तल से नीचे नहीं।

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// Result: max ±6 blocks variation, clamped to SEA_LEVEL+1

Forest: ऊँचाई 50, 4 कारक जैसे plains लेकिन ऊँचा आधार → जंगली पहाड़ियाँ।

Taiga: ऊँचाई 46, 3 कारक → मध्यम विविधताएँ, ठंडा terrain।

Snowy plains: ऊँचाई 40, केवल 2 कारक → सबसे उबड़-खाबड़।

h = (hash % 5) + ((hash >> 4) % 5);
// Result: max ±14 blocks variation

प्रत्येक biome 5 प्रविष्टियों की 3 सारणियों में एन्कोडेड है: आधार ऊँचाई, सतह ब्लॉक, कारकों की संख्या। जब getHeightAtFromHash biome प्राप्त करता है, तो यह terrain को समायोजित करने के लिए इन सारणियों से परामर्श करता है। Minecraft की पूरी biome प्रणाली को बदलने के लिए 15 बाइट्स डेटा।

biome डिटेक्टर यह निर्धारित करने के लिए seed का उपयोग करता है कि प्रत्येक chunk किस biome से मेल खाता है:

// worldgen.c (सरलीकृत)

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];
}

16 प्रविष्टियों का एक पैटर्न, एक index जो chunk के निर्देशांकों द्वारा seed किया गया है। यह एक दोहरावदार लेकिन दृश्य रूप से सुसंगत ग्रिड देता है। Minecraft vanilla की पूरी बायोमिक पैरामीटर प्रणाली को बदलने के लिए कोड की 4 लाइनें।

getHeightAtFromHash: terrain असेंबलर

जनरेशन के केंद्र में फ़ंक्शन biome द्वारा seed किए गए 4 कोनों को जोड़ता है:

// worldgen.c (सरलीकृत)

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);
  }

  // Interpolation from the 4 corners
  return interpolate(h1, h2, h3, h4, rx, rz);
}

प्रत्येक biome में एक biome_base होता है जो संदर्भ ऊँचाई को स्थानांतरित करता है, और 4 कोने हैश से अलग-अलग ऑफसेट के साथ निकाले जाते हैं। रेगिस्तान न्यूनतम को समुद्र तल से ऊपर बाध्य करता है -- एक बाधा रेखा जो अतिरिक्त बायोमिक गणना के बिना पानी से बचाती है।

पेड़ और कैक्टस: संभाव्य स्थापना

सतह जनरेशन यह तय करने के लिए उसी chunk हैश का उपयोग करता है कि कहाँ लगाया जाए:

// worldgen.c (सरलीकृत)

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 if 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);
    }
  }
}

हरे biomes के लिए 0-3 पेड़ प्रति chunk, रेगिस्तान के लिए अधिकतम 1 कैक्टस। chunk का हैश एंट्रॉपी का एकमात्र स्रोत है -- chunk में स्थिति के लिए & 7, काउंटर के लिए & 3। सब कुछ नियतिवादी है, कुछ भी संग्रहीत नहीं है।

generateChunk: सब कुछ एक साथ रखना

वह फ़ंक्शन जो 8×8×256 ब्लॉकों का पूरा chunk उत्पन्न करने के लिए सब कुछ एक साथ रखता है:

// worldgen.c (सरलीकृत)

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 column from 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);
}

बस इतना ही। 3 नेस्टेड लूप: प्रत्येक स्तंभ के लिए, ऊँचाई खोजें, ब्लॉक भरें, अगले पर जाएँ। आउटपुट एक uint8_t[16384] (8 × 8 × 256) है जो पूर्ण chunk का प्रतिनिधित्व करता है। कोई कैशिंग नहीं, कोई lazy loading नहीं, कोई संपीड़न नहीं -- chunk उत्पन्न होता है और सीधे क्लाइंट को भेजा जाता है।

संग्रहण: हर जगह स्थिर arrays

Bareiron की मेमोरी आर्किटेक्चर अपनी पूरी महिमा में एम्बेडेड C है। कोई malloc नहीं, कोई hash maps नहीं, कोई linked lists नहीं।

सब कुछ निश्चित आकार की वैश्विक सारणियों में है।

ब्लॉक परिवर्तन

// globals.h, लाइनें 191-196

typedef struct {
  short x;      // 2 bytes -- limits to 32,000 blocks horizontally
  short z;      // 2 bytes
  uint8_t y;    // 1 byte -- limits to 256 blocks vertically
  uint8_t block; // 1 byte -- limits to 256 block types
} BlockChange;

20,000 प्रविष्टियाँ, यानी लगभग 25,000 परिवर्तन -- डेढ़ chunk के बराबर पूरी तरह से खोदा गया। block फ़ील्ड पर 0xFF एक खाली प्रविष्टि को चिह्नित करता है। खोज एक रैखिक स्कैन है:

ब्लॉक सारणी का मेमोरी लेआउट -- 6 बाइट्स प्रति प्रविष्टि

// 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;
}

परिवर्तन जोड़ना खोज जितना ही सीधा है:

```c
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 ++;
}

एक काउंटर, एक इंडेक्स, एक राइट। कोई सॉर्टिंग नहीं, कोई कॉम्पैक्शन नहीं, कोई मेमोरी प्रबंधन नहीं। जब सारणी भर जाती है, नए परिवर्तनों को अनदेखा कर दिया जाता है -- terrain अपनी उत्पन्न स्थिति में वापस आ जाता है।

लेखक की टिप्पणी 256 ब्लॉकों की सीमा पर: "मैं जल्द ही थोड़े पेटिना वाली पॉलिश की गई तांबे की सीढ़ियाँ लागू करने की योजना नहीं बना रहा हूँ।"

Mobs: 8 बाइट्स प्रति सिर

// globals.h, लाइनें 240-251 (pragma pack(push, 1) 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 timer before deletion
  short z;
  uint8_t data;   // bits 0-4: health, bit 5: sheep sheared, bits 6-7: panic timer
} MobData;

8 बाइट्स। अधिकतम 16 स्लॉट। कोई alignment नहीं, कोई padding नहीं। data बाइट एक घरेलू bitfield है: स्वास्थ्य के 5 bits, शीयरिंग का 1 bit, पैनिक टाइमर के 2 bits। और जब कोई mob मरता है, तो Y फ़ील्ड हटाने से पहले एक टाइमर बन जाता है। बिट स्तर पर मेमोरी का पुन: उपयोग।

खिलाड़ी: कसकर पैक किए गए

खिलाड़ी डेटा भी #pragma pack(push, 1) का उपयोग करता है -- निर्देशांक short + uint8_t में, इन्वेंट्री uint16_t + uint8_t की निश्चित सारणियों में, और एक flags फ़ील्ड जो हमले के cooldown, spawn स्थिति, sneak, sprint, eat, load, movement cooldown, और craft lock को एन्कोड करता है। यह सब अलग-अलग bits में।

मुख्य लूप: while(true) और non-blocking

पूरा सर्वर एक लूप पर चलता है, एक thread, शून्य event library।

// main.c, लाइनें 594-720

while (true) {
  task_yield();  // let the watchdog 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 has 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 the 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);
}

लूप के प्रत्येक पुनरावृत्ति में केवल एक क्लाइंट को संभाला जाता है, और एक बार में केवल एक पैकेट पढ़ा जाता है। लूप की शुरुआत में task_yield() FreeRTOS idle task को ESP32 पर साँस लेने देता है -- इसके बिना, watchdog timer चिप को रीसेट कर देता है।

पैकेट डिस्पैच 400 लाइनों का एक राक्षसी switch है:

// main.c, लाइनें 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 (largest case)
    case 0x28:  // Player action (dig/place)
    // ... 40+ cases
  }
}

कोई डायनामिक jump table नहीं, कोई vtable नहीं, कोई map नहीं। एक switch स्टैटिक jump table में कंपाइल होता है। एम्बेडेड के लिए एकदम सही।

केस 0x1D-0x20 सबसे बड़ा है -- यह स्थिति अपडेट, गिरने की क्षति, chunk सीमा पार करना, mob spawn, chunk जनरेशन, और भूख को संभालता है। सब कुछ एक बड़े fall-through में।

Bareiron सर्वर कोड -- C की 6800 लाइनें

सर्वर tick और mob AI

handleServerTick फ़ंक्शन हर 50 ms (20 TPS) पर कॉल किया जाता है। यह दुनिया को संभालता है जबकि मुख्य लूप खिलाड़ियों का ध्यान रखता है:

// main.c (सरलीकृत)

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 up chunks around the mob
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

शत्रुतापूर्ण mobs की AI, यह निर्देशांकों की तुलना है। सचमुच if (px < x) x--। कोई pathfinding नहीं, कोई A* नहीं, कोई obstacle avoidance नहीं। ज़ोंबी खिलाड़ी की ओर स्वतंत्र रूप से X और Z को समायोजित करता है -- यदि दीवारें हों तो वह उनमें से गुज़रता है।

संपर्क क्षति 3 दिल/सेकंड है। p2r3 ने इसे उच्च रखा क्योंकि pathfinding की अनुपस्थिति ज़ोंबियों को kite करना आसान बनाती है।

कवच का फॉर्मूला combat update से पहले वाला है -- सबसे सरल संभव:

// main.c (सरलीकृत)

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;
}

पूर्ण diamond = 80% कमी। ज़ोंबी का 3 दिल का हमला 0.6 दिल हो जाता है। p2r3 ने यह पुराना फॉर्मूला चुना क्योंकि यह 2 ऑपरेशनों में गणना करता है -- कोई सीमा नहीं, कोई वक्र नहीं, बस एक रैखिक प्रतिशत।

निष्क्रिय mobs: एक lookup table में 8 दिशाएँ, हर ~40 टिक्स में दिशा बदलना। data फ़ील्ड शीर्ष 2 bits में वर्तमान दिशा और शेष 6 bits में दिशा परिवर्तन टाइमर को एन्कोड करता है।

Bareiron में Mobs -- ज़ोंबी, सूअर, भेड़

Mobs का respawn

Mobs random ticks के साथ spawn नहीं होते। वे तब प्रकट होते हैं जब सर्वर tick एक नई chunk सीमा का सामना करता है:

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;
  }
}

terrain के समान RNG, वही chunk seed। यदि कोई mob स्लॉट खाली है, तो spawn नियतिवादी है।

Crafting: कोई मैट्रिसेस नहीं, सिर्फ if/else

// crafting.c, लाइनें 9-347 (सरलीकृत)

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // If flag 0x80 is set, the craft buffer is being used by a chest
  if (player->flags & 0x80) { *count = 0; *item = 0; return; }

  // Count 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.) */
  }
}

पहली जाँच: यदि flag 0x80 सेट है, तो craft बफर को chest पॉइंटर के रूप में पुनर्चक्रित किया जा रहा है। कोई craft संभव नहीं।

फिर, यह भरे गए स्लॉट्स की गणना करता है, पहली item नोट करता है, समानता की जाँच करता है। बस इसी से, तुम furnace को 4 जाँचों में मिलान कर सकते हो:

if (count == 8 && first == cobblestone && all_identical && center_empty)
    return furnace;

जटिल आकृतियों के लिए, यह पहली item के इंडेक्स का उपयोग करता है और सापेक्ष स्थिति की जाँच करता है। रेसिपी एक ही मैचिंग फ़ंक्शन साझा करती हैं -- सामग्री परिणाम निर्धारित करती है।

Bareiron में craft और chest इंटरफ़ेस

Chests: असली हैक

वह मेमोरी हैक जिसके बारे में हर कोई बात करता है, असली कोड में:

// procedures.c, लाइनें 1262-1293

if (target == B_chest) {
  // Look for 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!!
  // Copy the POINTER into the player's craft items array
  memcpy(player->craft_items, &storage_ptr, sizeof(storage_ptr));
  player->flags |= 0x80;  // lock crafting

  // Send the chest interface 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);
  }
}

और कोड में टिप्पणी: // Terrible memory hack!!1!

बिल्कुल यही है। यह block_changes[] में अगली प्रविष्टि का मेमोरी पता लेता है, इसे player->craft_items में कॉपी करता है (जो एक uint16_t[9] है, यानी 18 बाइट्स -- 32-bit पॉइंटर स्टोर करने के लिए पर्याप्त), और flag उठाता है ताकि इस दौरान कोई craft करने का प्रयास न करे।

चेस्ट इन्वेंट्री में प्रत्येक क्लिक पर:

// packets.c, लाइनें 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;

यह craft बफर से पॉइंटर प्राप्त करता है, और ऑफसेट के साथ स्लॉट्स तक पहुँचता है। चेस्ट डेटा 3 बाइट्स प्रति स्लॉट (ID के लिए 2, मात्रा के लिए 1) की दर से, ब्लॉक सारणी में एक दूसरे से सटे हुए संग्रहीत होता है।

ब्लॉक सारणी में संग्रहीत चेस्ट डेटा -- एक मेमोरी हैक

भूख: प्रतिभा की 5 लाइनें

// main.c, लाइनें 293-305

// Players send movement packets at ~20/sec when moving,
// much less when standing still. We correlate this
// 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;
}

सचमुच बस इतना है। 5 लाइनें। प्रत्येक मूवमेंट पैकेट saturation को घटाता है। जब saturation शून्य पर पहुँचता है, भूख कम होती है और saturation रीसेट होता है। स्प्रिंट (flag 0x08) खपत को दोगुना करता है।

शून्य टाइमर, शून्य आवंटित मेमोरी, शून्य समर्पित गणना। एक काउंटर जो पहले से मौजूद पैकेटों पर घटता है।

गिरने की क्षति

प्रोजेक्ट की सबसे सरल क्षति प्रणाली:

// When the player leaves the ground, we store their Y
// When they touch the ground again, we subtract
damage = last_y_on_ground - current_y;

एक घटाव।

ब्लॉक खोदना और रखना

जब तुम किसी ब्लॉक पर क्लिक करते हो, तो पैकेट 0x28 (Player Action) switch में आता है। हैंडलर को यह निर्धारित करना होता है कि स्थान पर कौन सा ब्लॉक है, उसे हटाना है, और item को इन्वेंट्री में डालना है:

// main.c, case 0x28 (सरलीकृत)

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 update to 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 terrain जनरेशन और खिलाड़ी परिवर्तनों दोनों को जोड़ता है:

uint8_t getBlockAt (int x, uint8_t y, int z) {
  // First check player changes
  uint8_t change = getBlockChange(x, y, z);
  if (change != 0xFF) return change;

  // Otherwise, read from generated terrain
  return getTerrainBlock(x, y, z);
}

परिवर्तनों को प्राथमिकता, terrain पर fallback। शून्य बहस, शून्य कैश, शून्य ओवरहेड। हुड के नीचे getTerrainBlock, वह है getHeightAt + stone/dirt/grass/coal की परतें।

तत्काल furnace

सबसे मज़ेदार: furnace एक इकाई के रूप में मौजूद नहीं है। यदि तुम "पकाने" वाले स्लॉट में cobblestone और "ईंधन" में coal डालते हो, तो परिणाम तुरंत प्रकट होता है। कोई टाइमर नहीं, कोई chunk ticking नहीं। यह सिर्फ एक इन्वेंट्री स्लॉट है जो तब खाली होता है जब तुम सही items डालते हो।

तत्काल furnace -- सामग्री डालो, तुरंत परिणाम

ESP32 लूप: 4 KB स्टैक में MC सर्वर

// main.c, लाइनें 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();
  // Rest is handled by the event handler
}
#endif

पूरा सर्वर एक FreeRTOS टास्क में 4096 बाइट्स स्टैक के साथ चलता है। बस इतना। मुख्य थ्रेड केवल WiFi को इनिशियलाइज़ करता है और कनेक्शन की प्रतीक्षा करता है। एक बार कनेक्ट होने पर, यह bareiron_main को spawn करता है जो मानक main() को कॉल करता है।

सभी ESP32-विशिष्ट कोड #ifdef ESP_PLATFORM द्वारा संरक्षित हैं। PC पर, यह सब मानक POSIX कोड में कंपाइल होता है।

क्या बलिदान दिया गया

यह सब फिट करने के लिए, कुछ vanilla सुविधाएँ मौजूद नहीं हैं:

  • कोई नेटवर्क संपीड़न नहीं -- zlib बहुत महँगा। सर्वर chunks को तेज़ी से उत्पन्न करता है, लेकिन उन्हें भेजना bottleneck है।
  • कोई random ticks नहीं -- पेड़ bone meal से उगते हैं या नहीं। Mobs chunk सीमाओं पर spawn होते हैं।
  • कोई item entities नहीं -- खोदे गए ब्लॉक सीधे इन्वेंट्री में जाते हैं। एनिमेशन पूरी तरह से दृश्य है।
  • कोई इन्वेंट्री सत्यापन नहीं -- trust the client। 64 हीरे? OK। 1 सेकंड में पूरा chunk खोदा? OK। विश्वसनीय लोगों के बीच उपयोग के लिए।
  • कोई सर्वर साइड लाइट नहीं -- मशालें बाकी सब के बाद भेजी जाती हैं, क्लाइंट गणना करता है।
  • कोई क्रमिक तरल पदार्थ नहीं -- तत्काल अंतिम स्थिति।

अंतिम परिणाम

Ryzen 5 3600: ~0.5 ms प्रति chunk। 1$ की ESP32-C3: ~200 ms प्रति chunk। खेलने योग्य।

chunk जनरेशन बेंचमार्क -- Ryzen vs ESP32

3+ खिलाड़ी: लैग होता है। लेखक के अनुसार, पीक आवर्स पर 2b2t के बराबर।

एक ही Bareiron सर्वर से जुड़े कई खिलाड़ी

दर्शन

p2r3: "मुझे सिर्फ यह विचार पसंद है कि 1$ की यह छोटी सी चिप जो 0.5 Watt खपत करती है, Minecraft जैसी उन्नत चीज़ चला सकती है। Science isn't about 'why', it's about 'why not'."

हर लाइन एक समझौता है:

  • Perlin noise → interpolation: कम सुंदर, 200x तेज़, शून्य मेमोरी
  • Crafting matrices → hardcoded matching: गंदा कोड, शून्य बाइट्स
  • zlib → कुछ नहीं: खराब कनेक्शन = मौत, लेकिन खेलने योग्य
  • सत्यापन → विश्वास: शून्य सुरक्षा, शून्य गणना

हर अनुपस्थित सुविधा किसी दूसरी को हार्डवेयर की सीमाओं के भीतर अस्तित्व में आने देती है।

याद रखने योग्य 3 बातें:

  1. Interpolation + RNG -- 4 seed किए गए बिंदु, अनंत terrain, शून्य संग्रहण, chunk को पुन: उत्पन्न किए बिना क्वेरी, 200 ms जनरेशन। यह वह प्रतिभाशाली कदम है जो बाकी सब कुछ संभव बनाता है।
  2. हर सुविधा की एक लागत है -- कोई संपीड़न नहीं, कोई random ticks नहीं, कोई सत्यापन नहीं। ये भूल नहीं हैं, यही 520 KB में फिट होने का तरीका है।
  3. गंदे हैक्स सबसे चतुर होते हैं -- memcpy के माध्यम से ब्लॉक सारणी में chests, मूवमेंट पैकेट द्वारा भूख, तत्काल furnace। साफ समाधान बहुत महँगा होता।

अगर यह प्रोजेक्ट आपकी रुचि रखता है, तो सब कुछ GitHub पर GPLv3 में है। यह गंदा C है, और मैंने शायद ही कभी किसी कोड को पढ़ने में इतना मज़ा लिया हो xD

Bareiron -- خادم Minecraft الذي يعمل على متحكم دقيق بـ 1$

6800 سطر من C، بدون malloc، استبدال Perlin noise بـ bilinear interpolation،

مقدمة

هل تساءلت يوماً إن كان بإمكانك تشغيل خادم Minecraft على متحكم دقيق بـ 1 دولار؟

أنا فعلت. والجواب هو نعم. حرفياً.

هناك مشروع اسمه Bareiron، من توقيع p2r3، وهو على الأرجح واحد من أكثر المشاريع إثارة للدهشة التي رأيتها في عالم Minecraft في السنوات الأخيرة. نحن نتحدث عن ملف ثنائي بحجم 300 كيلوبايت، 6800 سطر من C، بدون أي تبعيات خارجية، بدون malloc، بدون threading، وكل هذا يعمل على ESP32 بـ 1 دولار.

ESP32-C3، المتحكم الدقيق الذي يشغل الخادم

توليد تضاريس لا نهائي. Biomes. كهوف. تصنيع. تعدين. Mobs. جوع. صناديق. كل ما تتوقعه من خادم survival.

على شريحة تستهلك 0.5 واط ولديها 160 MHz تردد.

لإعطائك فكرة: خادم Minecraft vanilla يحتاج إلى عدة غيغابايت من RAM. ESP32-C3 لديه 520 KB من SRAM (400 متاحة بعد الإقلاع). المعالجات قبل 20 سنة كانت تعمل بغيجاهيرتز -- هذا المعالج يصل إلى 160 MHz. الفرق بينهما في القوة الخام هو حوالي 20,000.

p2r3 لم يكتب خادم Minecraft بلغة C، بل أعاد اختراع كل لبنة من الخادم لتتناسب مع هذه القيود. دعنا نرى كيف، من خلال فتح الكود المصدري.

صورة مصغرة لفيديو تقديم Bareiron من p2r3

عقل المشروع: توليد تضاريس بدون ذاكرة

أكبر مشكلة عندما تريد بناء خادم MC مضمن هي توليد التضاريس.

في Minecraft vanilla، يتم توليد العالم باستخدام Perlin noise: عدة طبقات متراكبة (octaves)، 6 معاملات بيومية (درجة الحرارة، الرطوبة، القارية، التآكل، الغرابة، العمق)، ونظام كامل للتخزين المؤقت لتجنب إعادة الحساب في كل مرة.

النتيجة رائعة. لكنها مكلفة حسابياً، وتستهلك RAM لتخزين الـ chunks المولدة.

منهج Bareiron مختلف جذرياً. بدلاً من تكديس الضوضاء، يستخدم bilinear interpolation على 4 نقاط مولدة بواسطة RNG حتمي.

هل تعلم عندما تكبر صورة صغيرة منخفضة الدقة وتصبح الحواف ضبابية؟ هذا بالضبط ما يحدث.

// worldgen.c, lignes 117-171 (simplifié)

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);
  // interpolation between 4 corners seeded by hash + biome
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

Bilinear interpolation القياسي: 4 زوايا، أوزان حسب الموضع، uint8_t واحد في المخرجات. CHUNK_SIZE يساوي 8، لذا يتم كل شيء بضرب أعداد صحيحة، بدون float.

p2r3 يشرحها خطوة بخطوة في الفيديو: أولاً الزوايا الأربع للـ chunk، كل منها بارتفاع معين مبدئ بواسطة RNG.

الزوايا الأربع للـ chunk، كل منها مبدئ بواسطة RNG الحتمي

ثم interpolation بين هذه النقاط الأربع يُنشئ سطحاً مستمراً.

تطبيق bilinear interpolation بين الزوايا الأربع

وبتكرار النمط على جميع الـ chunks المجاورة، نحصل على تضاريس تمتد إلى ما لا نهاية.

النتيجة النهائية: تضاريس غير منتظمة ومستمرة

RNG الحتمي

المفتاح الذي يجعل كل هذا ممكناً هو التبدئة (seeding). كل chunk له 4 زوايا، وكل زاوية تحتاج إلى قيمة شبه عشوائية فريدة لكن قابلة لإعادة الإنتاج.

// worldgen.c, lignes 13-22

uint32_t getChunkHash (short x, short z) {
  uint8_t buf[8];
  memcpy(buf, &x, 2);          // 16 bits de coordonnée X
  memcpy(buf + 2, &z, 2);      // 16 bits de coordonnée Z
  memcpy(buf + 4, &world_seed, 4);  // 32 bits de seed globale
  return splitmix64(*((uint64_t *)buf));  // hash
}

يضع 16 بت من X، 16 بت من Z، و32 بت من seed، في مخزن مؤقت بحجم 8 بايت، ويمرر كل شيء إلى splitmix64. النتيجة: قيمة حتمية فريدة لكل موضع، بناءً على seed العالم.

هل تدرك قوة هذا؟ الخادم لا يحتاج إلى تخزين التضاريس. إنه يعيد الحساب بسرعة عندما يصل اللاعب إلى منطقة جديدة، ويعطي نفس النتيجة تماماً في كل مرة.

splitmix64 المستخدم هو prng فائق السرعة مصمم لتجزئة 64 بت:

// worldgen.c (simplifié)

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 عمليات: جمع، xor/إزاحة، ضرب، xor/إزاحة، ضرب، xor/إزاحة. لا جدول بحث، لا حلقة. يأخذ المخزن المؤقت بـ 8 بايت (X + Z + seed)، يعامله كعدد صحيح 64 بت، ويعيد 32 بت من التجزئة. إنه حتمي، سريع، ويناسب 5 أسطر.

لماذا ليس Perlin noise

p2r3 يقولها بنفسه في الفيديو: "كلما أضفت أرقاماً أكثر من الرقم العشوائي، أصبح التضاريس أكثر انتظاماً، مثل رمي العملة مرات أكثر يقربك من 50/50". عملياً، هو عدد بتات التجزئة التي يجمعها:

// worldgen.c, lignes 51-115

// Pour un biome plains : 4 facteurs combinés → terrain régulier
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Pour snowy plains : 2 facteurs → plus accidenté
h = (hash % 5) + ((hash >> 4) % 5);

كل biome يختار عدد استخراجات البتات التي يجمعها. كلما زاد العدد، استقر التوزيع -- مثل رمي العملات الذي يقترب من 50/50. كلما قل العدد، زادت التغيرات المحلية.

تضاريس غير منتظمة -- عوامل قليلة، تغيرات قوية

مع عاملين فقط، snowy plains تنتج تضاريساً تلالية، شبه جبلية. القمم والانخفاضات متكررة.

تضاريس منتظمة -- عوامل متعددة، سطح أملس

مع 4 عوامل، تبقى السهول مسطحة وقابلة للتنبؤ. التوزيع يستقر.

يتم توليد chunk واحد في 200 ms على ESP32 -- مقابل وقت غير قابل للقياس على نفس العتاد مع Perlin noise لمثل هذه التكلفة.

التفصيلة القاتلة: الاستعلام عن كتلة بدون توليد الـ chunk بالكامل

أنت تلعب، أنت تكسر كتلة. الخادم يجب أن يعرف أي عنصر يعطيك. بشكل ساذج، ستحتاج إلى توليد الـ chunk بالكامل لذلك.

مع bilinear interpolation، تستعلم عن أي نقطة على المستوى مباشرة من الإحداثيات. زوايا الـ chunk تُحصل من موقع اللاعب، interpolation يعطيك الارتفاع عند أي إزاحة. حفنة من العمليات الرياضية، لا توليد chunk.

p2r3: "ما أريده هو دالة سحرية يمكنها إخباري أي كتلة توجد في إحداثية معينة، دون الوصول إلى الذاكرة أو حساب خرائط ضوضاء باهظة". وهذا بالضبط ما فعله.

هكذا يصبح الارتفاع كتلًا ملموسة:

// worldgen.c (simplifié)

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 شروط. طبقة من grass/dirt/stone/deepslate/bedrock. كتلة السطح تعتمد على biome عبر biome_top[] -- grass للسهول، sand للصحراء. لا حلقة، لا switch، سلسلة من if تسقط في الطبقة المناسبة.

الكهوف، عكس السطح الأكثر كسلاً

altitude_grotte = CAVE_BASE_DEPTH - (hauteur_surface - y);

إنه يعكس ارتفاع السطح تحت الأرض. يبدو مثل تجاويف deepslate الكبيرة. بدون أي حساب، سطر واحد.

كهوف مولدة بعكس التضاريس السطحية

رسم تخطيطي لعكس التضاريس لتوليد الكهوف

الخامات، بنسخة XOR

candidat = (chunk_x ^ col_x ^ col_z) % 100;
if (candidat < 5 && y < 16) -> diamond

XOR الإحداثيات يضمن مرشحاً واحداً لكل عمود. النوع يعتمد فقط على الارتفاع. الماس مخبأ تحت أدنى نقطة في الكهوف ليبقى الحفر مفيداً.

الـ biomes في tile map

كل biome هو جزيرة دائرية في شبكة، نوعه محدد بنمط محسوب من الـ seed. شبكي، قابل للتنبؤ، ومجاني.

خريطة الـ biomes في tile map -- كل جزيرة هي biome مختلف

كل biome لديه مجموعته الخاصة من المعاملات المشفرة في مصفوفات:

// worldgen.c (simplifié)

static const uint8_t biome_base[] = {
  [BIOME_PLAINS]  = 48,   // hauteur de base : 48
  [BIOME_DESERT]  = 52,   // légèrement plus haut
  [BIOME_FOREST]  = 50,   // entre les deux
  [BIOME_TAIGA]   = 46,   // un peu plus bas
  [BIOME_SNOWY]   = 40,   // le plus bas
};

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 → très régulier
  [BIOME_DESERT]  = 3,   // 3 extractions → modéré
  [BIOME_FOREST]  = 4,   // 4 extractions → régulier, vallonné
  [BIOME_TAIGA]   = 3,   // 3 extractions → modéré
  [BIOME_SNOWY]   = 2,   // 2 extractions → très accidenté
};

Plains: ارتفاع 48، 4 عوامل → تضاريس مسطحة جداً، عشب.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Résultat : variation de ±4 blocs max

Desert: ارتفاع 52، 3 عوامل، كتلة سطح = رمل. أبداً تحت مستوى سطح البحر.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// Résultat : variation de ±6 blocs max, clampé à SEA_LEVEL+1

Forest: ارتفاع 50، 4 عوامل مثل السهول لكن بقاعدة أعلى → تلال مشجرة.

Taiga: ارتفاع 46، 3 عوامل → تغيرات معتدلة، تضاريس باردة.

Snowy plains: ارتفاع 40، عاملان فقط → الأكثر وعورة.

h = (hash % 5) + ((hash >> 4) % 5);
// Résultat : variation de ±14 blocs max

كل biome مشفر في 3 مصفوفات من 5 مداخل: الارتفاع الأساسي، كتلة السطح، عدد العوامل. عندما تستقبل getHeightAtFromHash الـ biome، تستشير هذه المصفوفات لضبط التضاريس. 15 بايت من البيانات لاستبدال نظام الـ biomes بالكامل في Minecraft.

كاشف الـ biome يستخدم الـ seed لتحديد أي biome يناظر كل chunk:

// worldgen.c (simplifié)

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];
}

نمط من 16 مدخلاً، فهرس مبدئ بإحداثيات الـ chunk. يعطي شبكة متكررة لكنها متسقة بصرياً. 4 أسطر من الكود لاستبدال نظام معاملات الـ biomes بالكامل في Minecraft vanilla.

getHeightAtFromHash: مجمع التضاريس

الدالة في قلب التوليد تجمع الزوايا الأربع المبدئة بـ biome:

// worldgen.c (simplifié)

static uint8_t getHeightAtFromHash (int rx, int rz, short cx, short cz,
                                    uint32_t h, uint8_t biome) {
  // 4 coins extraits du hash, seed différente par coin
  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);

  // Contrainte biome : désert jamais sous l'eau
  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);
  }

  // Interpolation depuis les 4 coins
  return interpolate(h1, h2, h3, h4, rx, rz);
}

كل biome لديه biome_base يزيح ارتفاع الأساس، والزوايا الأربع مستخرجة من التجزئة بإزاحات مختلفة. الصحراء تفرض الحد الأدنى فوق مستوى سطح البحر -- سطر قيد واحد يتجنب الماء دون حساب بيومي إضافي.

الأشجار والصبار: وضع احتمالي

توليد السطح يستخدم نفس تجزئة الـ chunk لتقرير أين يزرع:

// worldgen.c (simplifié)

static void genFoliage (uint8_t *chunk_data, short cx, short cz,
                        uint32_t hash, uint8_t biome) {
  if (biome == BIOME_DESERT) {
    // Cactus : un candidat par chunk, hash détermine la 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 {
    // Arbres : hash détermine si on en pose et où
    int tree_count = (hash & 3);  // 0-3 arbres par 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 أشجار لكل chunk للـ biomes الخضراء، صبار واحد كحد أقصى للصحراء. تجزئة الـ chunk هي مصدر العشوائية الوحيد -- & 7 للموضع داخل الـ chunk، & 3 للعداد. كل شيء حتمي، لا شيء مخزّن.

generateChunk: تجميع كل شيء

الدالة التي تجمع كل شيء لإنتاج chunk كامل من 8×8×256 كتلة:

// worldgen.c (simplifié)

void generateChunk (uint8_t *chunk, short cx, short cz) {
  uint32_t hash = getChunkHash(cx, cz);
  uint8_t biome = getChunkBiome(cx, cz);

  // Pour chaque colonne du chunk (8×8 = 64)
  for (int x = 0; x < 8; x ++) {
    for (int z = 0; z < 8; z ++) {
      // Coordonnées monde absolues
      int wx = cx * 8 + x;
      int wz = cz * 8 + z;

      // Hauteur de la colonne
      uint8_t height = getHeightAt(wx, wz);

      // Remplir la colonne de bas en haut
      for (int y = 0; y < height; y ++) {
        uint8_t block = getTerrainBlock(wx, y, wz);
        chunk[y * 64 + z * 8 + x] = block;
      }
    }
  }

  // Ajouter les éléments de surface (arbres, cactus)
  genFoliage(chunk, cx, cz, hash, biome);
}

هذا كل شيء. 3 حلقات متداخلة: لكل عمود، جد الارتفاع، املأ الكتل، انتقل للتالي. المخرجات هي uint8_t[16384] (8 × 8 × 256) تمثل الـ chunk الكامل. لا تخزين مؤقت، لا تحميل كسول، لا ضغط -- يتم توليد الـ chunk وإرساله مباشرة إلى العميل.

التخزين: مصفوفات ثابتة في كل مكان

هندسة الذاكرة في Bareiron هي C مضمن بكل روعتها. لا malloc، لا hash maps، لا قوائم مرتبطة.

كل شيء في مصفوفات عامة ذات حجم ثابت.

تغييرات الكتل

// globals.h, lignes 191-196

typedef struct {
  short x;      // 2 bytes -- limite à 32 000 blocs horizontal
  short z;      // 2 bytes
  uint8_t y;    // 1 byte -- limite à 256 blocs vertical
  uint8_t block; // 1 byte -- limite à 256 types de blocs
} BlockChange;

20,000 مدخل، أي حوالي 25,000 تغيير -- ما يعادل chunk ونصف مكشوف بالكامل. الحقل block بقيمة 0xFF يحدد مدخلاً حراً. البحث هو مسح خطي:

تخطيط ذاكرة مصفوفة الكتل -- 6 بايت لكل مدخل

// 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;
}

إضافة تغيير هي بنفس بساطة البحث:

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 ++;
}

عداد، فهرس، كتابة. لا ترتيب، لا ضغط، لا إدارة ذاكرة. عندما تمتلئ المصفوفة، يتم تجاهل التغييرات الجديدة -- تعود التضاريس إلى حالتها المولدة.

تعليق المؤلف على حد 256 كتلة: "لا أخطط لتنفيذ السلالم النحاسية المصقولة قليلاً في أي وقت قريب."

الـ mobs: 8 بايت لكل رأس

// globals.h, lignes 240-251 (pragma pack(push, 1) pour éliminer le padding)

typedef struct {
  uint8_t type;   // 25=chicken, 28=cow, 95=pig, 106=sheep, 145=zombie
  short x;
  uint8_t y;      // si health=0, Y devient un timer avant suppression
  short z;
  uint8_t data;   // bits 0-4: health, bit 5: sheep sheared, bits 6-7: panic timer
} MobData;

8 بايت. 16 موضعاً كحد أقصى. لا محاذاة، لا حشو. بايت data هو bitfield منزلي: 5 بت للصحة، 1 بت للجز، 2 بت لمؤقت الذعر. وعندما يموت mob، يصبح الحقل Y مؤقتاً قبل الحذف. إعادة استخدام ذاكرة على مستوى البت.

اللاعبون: معبؤون بإحكام

بيانات اللاعبين تستخدم #pragma pack(push, 1) أيضاً -- إحداثيات بـ short + uint8_t، مخزون في مصفوفات ثابتة من uint16_t + uint8_t، وحقل flags يشفر كل من مهلة الهجوم، حالة الظهور، التسلل، الركض، الأكل، التحميل، مهلة الحركة، وقفل التصنيع. كل ذلك في بتات فردية.

الحلقة الرئيسية: while(true) وعدم الحظر

الخادم بأكمله يعمل على حلقة واحدة، خيط واحد، بدون مكتبة أحداث.

// main.c, lignes 594-720

while (true) {
  task_yield();  // laisse respirer le watchdog sur ESP32

  // Accepter une nouvelle connexion (non-bloquant)
  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;
  }

  // Tick serveur si le temps est écoulé
  if (get_program_time() - last_tick_time > TIME_BETWEEN_TICKS) {
    handleServerTick(time_since_last_tick);
    last_tick_time = get_program_time();
  }

  // Round-robin : un client, un packet par itération
  client_index = (client_index + 1) % MAX_PLAYERS;
  if (clients[client_index] == -1) continue;

  // Lire l'entête du packet : 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);
}

يتم معالجة عميل واحد فقط لكل تكرار للحلقة، ويتم قراءة حزمة واحدة فقط في كل مرة. task_yield() في بداية الحلقة يسمح لمهمة FreeRTOS الخاملة بالتنفس على ESP32 -- بدونها، مؤقت المراقبة (watchdog timer) يعيد تشغيل الشريحة.

توزيع الحزم هو switch ضخم من 400 سطر:

// main.c, lignes 68-497

void handlePacket (int client_fd, int length, int packet_id, int state) {
  switch (packet_id) {
    case 0x00:  // Handshake / Status / Login selon l'état
    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 (gère les coffres)
    case 0x19:  // Interact entity
    case 0x1D..0x20:  // Movement packets (le plus gros cas)
    case 0x28:  // Player action (dig/place)
    // ... 40+ cas
  }
}

لا jump table ديناميكي، لا vtable، لا map. switch يترجم إلى jump table ثابت. مثالي للأنظمة المضمنة.

الحالة 0x1D-0x20 هي الأكبر -- تتعامل مع تحديثات الموضع، ضرر السقوط، عبور حدود الـ chunk، ظهور الـ mobs، توليد الـ chunks، والجوع أيضاً. كل شيء في fall-through واحد كبير.

كود خادم Bareiron -- 6800 سطر من C

tick الخادم وذكاء الـ mobs

الدالة handleServerTick تُستدعى كل 50 ms (20 TPS). تدير العالم بينما الحلقة الرئيسية تتعامل مع اللاعبين:

// main.c (simplifié)

void handleServerTick (uint32_t delta) {
  // Mettre à jour chaque mob
  for (int i = 0; i < MOB_COUNT; i ++) {
    if (mobs[i].type == 0 || mobs[i].data == 0) continue;  // mort ou vide

    MobData *mob = &mobs[i];
    int px, pz;
    getNearestPlayer(mob->x, mob->z, &px, &pz);

    if (mob->type == MOB_ZOMBIE) {
      // Hostile : marche vers le joueur le plus proche
      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 ++;
      // Dégâts de contact à 2 blocs
      if (abs(px - mob->x) <= 2 && abs(pz - mob->z) <= 2)
        damagePlayer(getNearestPlayerId(mob->x, mob->z), 3);
    } else {
      // Passif : 8 directions aléatoires
      uint8_t dir = getMobDir(mob);
      mob->x += dir_lookup[dir][0];
      mob->z += dir_lookup[dir][1];
      // Changement de direction toutes les ~40 ticks
      if (mob->data >> 6 < 1) setMobDir(mob, rand() & 7);
      mob->data = (mob->data & 0x3F) | ((mob->data - 0x40) & 0xC0);
    }

    // Réveiller les chunks autour du mob
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

ذكاء الـ mobs العدائية هو مقارنة إحداثيات. حرفياً if (px < x) x--. لا pathfinding، لا A*، لا تجنب عوائق. الزومبي يضبط X و Z بشكل مستقل نحو اللاعب -- يعبر الجدران إن وجدت.

ضرر التلامس هو 3 قلوب/ثانية. p2r3 جعلها عالية لأن عدم وجود pathfinding يجعل الزومبي سهل المراوغة.

معادلة الدرع هي من قبل تحديث القتال -- الأبسط الممكنة:

// main.c (simplifié)

static uint8_t applyArmor (uint8_t damage, uint16_t armor_value) {
  // Formule pré-1.9 : réduction linéaire
  // Chaque point d'armure = 4% de réduction, max 80%
  uint8_t reduction = (armor_value * 4);
  if (reduction > 80) reduction = 80;
  return damage * (100 - reduction) / 100;
}

Full diamond = تخفيض 80%. ضربة زومبي بـ 3 قلوب تصبح 0.6 قلب. p2r3 اختار هذه المعادلة القديمة لأنها تُحسب في عمليتين -- لا عتبات، لا منحنيات، مجرد نسبة مئوية خطية.

الـ mobs السلبية: 8 اتجاهات في جدول بحث، تغيير اتجاه كل ~40 tick. حقل data يشفر الاتجاه الحالي في أعلى بتين، ومؤقت تغيير الاتجاه في الـ 6 بتات المتبقية.

Mobs في Bareiron -- زومبي، خنازير، أغنام

إعادة ظهور الـ mobs

الـ mobs لا تظهر مع random ticks. تظهر عندما يصادف tick الخادم حدود chunk جديدة:

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;
  }
}

نفس RNG المستخدم للتضاريس، نفس seed الـ chunk. إذا كان موضع mob شاغراً، يكون الظهور حتمياً.

التصنيع: لا مصفوفات، if/else

// crafting.c, lignes 9-347 (simplifié)

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // Si le flag 0x80 est levé, le buffer de craft est utilisé par un coffre
  if (player->flags & 0x80) { *count = 0; *item = 0; return; }

  // Compter les slots, trouver le premier item, vérifier l'identité
  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:  /* planches, lingots... */
    case 2:  /* bâtons, cisailles, torches */
    case 3:  /* pelles, épées, dalles */
    case 4:  /* table de craft, boots */
    case 5:  /* pioches, haches, casques */
    case 7:  /* jambières, composteurs */
    case 8:  /* fourneau, coffre, plastron */
    case 9:  /* blocs complets (fer, or, etc.) */
  }
}

الاختيار الأول: إذا كان العلم 0x80 مرفوعاً، فإن مخزن التصنيع يُعاد استخدامه كمؤشر صندوق. لا تصنيع ممكن.

ثم، يعد الفتحات المملوءة، يلاحظ أول عنصر، يتحقق من التطابق. بهذا فقط، يطابق الفرن في 4 اختيارات:

if (count == 8 && first == cobblestone && all_identical && center_empty)
    return furnace;

للأشكال المعقدة، يستخدم فهرس أول عنصر ويتحقق من الموضع النسبي. الوصفات تشارك نفس دالة المطابقة -- المادة تحدد النتيجة.

واجهة التصنيع والصندوق في Bareiron

الصناديق: الاختراق الحقيقي

اختراق الذاكرة الذي يتحدث عنه الجميع، في الكود الحقيقي:

// procedures.c, lignes 1262-1293

if (target == B_chest) {
  // Chercher l'entrée du coffre dans le tableau des blocs
  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]);  // pointe après le bloc coffre
    break;
  }
  if (storage_ptr == NULL) return;

  // Terrible memory hack!!
  // On copie le POINTEUR dans le tableau d'items de craft du joueur
  memcpy(player->craft_items, &storage_ptr, sizeof(storage_ptr));
  player->flags |= 0x80;  // lock le craft

  // Envoyer l'interface coffre au 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);
  }
}

والتعليق في الكود: // Terrible memory hack!!1!

هذا بالضبط ما هو. يأخذ عنوان الذاكرة للمدخل التالي في block_changes[]، ينسخه إلى player->craft_items (وهو uint16_t[9]، أي 18 بايت -- كافية لتخزين مؤشر 32 بت)، ويرفع العلم ليمنع أي شخص من التصنيع خلال هذا الوقت.

عند كل نقرة في مخزون الصندوق:

// packets.c, lignes 620-638

uint8_t *storage_ptr;
memcpy(&storage_ptr, player->craft_items, sizeof(storage_ptr));
// storage_ptr pointe maintenant vers les données du coffre
uint16_t *p_item = (uint16_t *)(storage_ptr + (slot - 41) * 3);
uint8_t *p_count = storage_ptr + (slot - 41) * 3 + 2;

يستعيد المؤشر من مخزن التصنيع، ويصل إلى الفتحات بإزاحة. بيانات الصندوق مخزنة بمعدل 3 بايت لكل فتحة (2 للمعرف، 1 للكمية)، ملتصقة ببعضها في مصفوفة الكتل.

بيانات الصندوق المخزنة في مصفوفة الكتل -- اختراق ذاكرة

الجوع: 5 أسطر من العبقرية

// main.c, lignes 293-305

// Les joueurs envoient des packets de mouvement à ~20/sec quand ils
// bougent, beaucoup moins quand ils sont immobiles. On corrèle ça
// avec l'activité pour simuler la faim gratuitement.
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;
}
}

هذا حرفياً كل شيء. 5 أسطر. كل حزمة حركة تنقص الشبع. عندما يصل الشبع إلى الصفر، ينخفض الجوع وتُعاد ضبط الشبع. الركض (العلم 0x08) يضاعف الاستنزاف.

لا مؤقت، لا ذاكرة مخصصة، لا حساب مخصص. عداد يتناقص على حزم موجودة أصلاً.

ضرر السقوط

أبسط نظام ضرر في المشروع:

// Quand le joueur quitte le sol, on stocke son Y
// Quand il retouche le sol, on soustrait
degats = dernier_y_au_sol - y_actuel;

عملية طرح.

التعدين ووضع الكتل

عندما تنقر على كتلة، تصل الحزمة 0x28 (Player Action) إلى الـ switch. المعالج يجب أن يحدد أي كتلة في الموضع، يزيلها، ويضع العنصر في المخزون:

// main.c, case 0x28 (simplifié)

void handlePlayerAction (int client_fd, uint8_t action, int x, uint8_t y, int z) {
  switch (action) {
    case START_DESTROY_BLOCK: {
      // Déterminer le type de bloc à la position cliquée
      uint8_t block = getBlockAt(x, y, z);

      if (block == B_chest) {
        openChest(client_fd, x, y, z);
        break;
      }

      // Ajouter aux block_changes
      addBlockChange(x, z, y, 0);  // 0 = air

      // Donner l'item au joueur (trust the client)
      addItemToPlayer(client_fd, block_to_item(block), 1);

      // Envoyer la mise à jour au client
      sc_blockChange(client_fd, x, y, z, 0);
      sc_ackBlockChange(client_fd, x, y, z, 0);
      break;
    }
    case PLACE_BLOCK: {
      // Lire le type de bloc depuis la main du joueur
      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 تجمع بين توليد التضاريس وتغييرات اللاعبين:

uint8_t getBlockAt (int x, uint8_t y, int z) {
  // D'abord vérifier les changements joueurs
  uint8_t change = getBlockChange(x, y, z);
  if (change != 0xFF) return change;

  // Sinon, lire depuis le terrain généré
  return getTerrainBlock(x, y, z);
}

أولوية للتغييرات، تراجع للتضاريس. لا نقاش، لا مخبأ، لا حمل زائد. getTerrainBlock تحت الغطاء هو getHeightAt + طبقات stone/dirt/grass/coal.

الفرن الفوري

الأكثر تسلية: الفرن غير موجود ككيان. إذا وضعت cobblestone في خانة "الطبخ" و coal في "الوقود"، تظهر النتيجة فوراً. لا مؤقت، لا chunk ticking. إنها مجرد خانة مخزون تفرغ عندما تضع العناصر الصحيحة.

فرن فوري -- ضع المكونات، النتيجة فورية

حلقة ESP32: خادم MC في 4 KB من stack

// main.c, lignes 732-779

#ifdef ESP_PLATFORM

void bareiron_main (void *pvParameters) {
  main();
  vTaskDelete(NULL);
}

static void wifi_event_handler (...) {
  if (/* connecté */) {
    xTaskCreate(bareiron_main, "bareiron", 4096, NULL, 5, NULL);
  }
}

void app_main () {
  esp_timer_early_init();
  wifi_init();
  // Le reste est géré par le event handler
}
#endif

الخادم بأكمله يعمل في مهمة FreeRTOS مع 4096 بايت من stack. هذا كل شيء. الخيط الرئيسي فقط يهيئ WiFi وينتظر اتصالاً. بمجرد الاتصال، يشغل bareiron_main التي تستدعي main() القياسي.

كل كود ESP32 المحدد محمي بـ #ifdef ESP_PLATFORM. على PC، كل هذا يترجم إلى كود POSIX قياسي.

ما تم التضحية به

لكي يعمل كل هذا، هناك ميزات vanilla غير موجودة:

  • لا ضغط شبكة -- zlib مكلف جداً. الخادم يولد الـ chunks بسرعة، لكن إرسالها هو عنق الزجاجة.
  • لا random ticks -- الأشجار تنمو بـ bone meal أو لا. الـ mobs تظهر عند حدود الـ chunk.
  • لا كيانات عناصر -- الكتل المكسورة تذهب مباشرة للمخزون. الحركة المرئية بحتة.
  • لا تحقق من المخزون -- trust the client. 64 ماسة؟ حسناً. chunk مكشوف في 1 ثانية؟ حسناً. للاستخدام بين الأشخاص الموثوقين.
  • لا إضاءة خادم -- المشاعل تُرسل بعد كل شيء آخر، العميل يحسب.
  • لا سوائل تدريجية -- حالة نهائية فورية.

النتيجة النهائية

Ryzen 5 3600: ~0.5 ms لكل chunk. ESP32-C3 بـ 1$: ~200 ms لكل chunk. قابل للعب.

قياس أداء توليد الـ chunks -- Ryzen مقابل ESP32

3+ لاعبين: يبدأ بالتأخير. مشابه لـ 2b2t في ساعات الذروة، حسب قول المؤلف.

عدة لاعبين متصلين بنفس خادم Bareiron

الفلسفة

p2r3: "أنا فقط أحب فكرة أن هذه الشريحة الصغيرة جداً بـ 1 دولار التي تستهلك 0.5 واط يمكنها تشغيل شيء متقدم مثل Minecraft. Science isn't about 'why', it's about 'why not'."

كل سطر هو مقايضة:

  • Perlin noise ← interpolation: أقل جمالاً، أسرع بـ 200x، بدون ذاكرة
  • مصفوفات التصنيع ← مطابقة مبرمجة: كود قذر، بدون بايتات
  • zlib ← لا شيء: اتصال سيء = موت، لكن قابل للعب
  • تحقق ← ثقة: لا أمان، لا حساب

كل ميزة غائبة تسمح لأخرى بالوجود ضمن حدود العتاد.

3 أشياء يجب تذكرها:

  1. Interpolation + RNG -- 4 نقاط مبدئة، تضاريس لا نهائية، بدون تخزين، استعلام بدون إعادة توليد chunk، 200 ms توليد. هذه هي الحركة العبقرية التي تجعل كل شيء آخر ممكناً.
  2. كل ميزة لها تكلفة -- لا ضغط، لا random ticks، لا تحقق. هذه ليست سهواً، بل ما يسمح بالعمل ضمن 520 KB.
  3. الاختراقات القذرة هي الأكثر ذكاءً -- صناديق في مصفوفة الكتل عبر memcpy، جوع عبر حزم الحركة، فرن فوري. الحل النظيف كان سيكون مكلفاً جداً.

إذا كان المشروع يثير اهتمامك، كل شيء على GitHub برخصة GPLv3. إنه C قذر جداً، ونادراً ما استمتعت بقراءة كود مصدري بهذا القدر xD

Bareiron -- máy chủ Minecraft chạy trên vi điều khiển giá 1$

6800 dòng C, zero malloc, Perlin noise được thay thế bằng bilinear

Giới thiệu

Bạn đã bao giờ tự hỏi liệu có thể chạy một máy chủ Minecraft trên một vi điều khiển giá 1$ không?

Tôi đã từng. Và câu trả lời là có. Theo nghĩa đen.

Có một dự án tên là Bareiron, của p2r3, và đó có lẽ là một trong những dự án hấp dẫn nhất tôi từng thấy trong thế giới Minecraft những năm gần đây. Chúng ta đang nói về một binary chỉ nặng 300 kilobyte, 6800 dòng C, không phụ thuộc bên ngoài, không malloc, không threading, và chạy trên một ESP32 giá 1 đô la.

ESP32-C3, vi điều khiển chạy máy chủ

Tạo địa hình vô tận. Biome. Hang động. Chế tạo. Đào mỏ. Sinh vật. Đói. Rương. Tất cả những gì bạn mong đợi từ một máy chủ survival.

Trên một con chip tiêu thụ 0.5 Watt và có xung nhịp 160 MHz.

Để dễ hình dung: một máy chủ Minecraft vanilla cần vài GB RAM. ESP32-C3 chỉ có 520 KB SRAM (400 KB khả dụng sau khi boot). Bộ vi xử lý cách đây 20 năm đã chạy ở GHz -- con chip này chỉ đạt tối đa 160 MHz. Hệ số chênh lệch về sức mạnh thuần túy là khoảng 20 000.

p2r3 không chỉ viết một máy chủ Minecraft bằng C, anh ấy đã phát minh lại từng viên gạch của máy chủ để nó vừa vặn trong những giới hạn đó. Hãy cùng xem bằng cách mở mã nguồn.

Hình thu nhỏ video giới thiệu Bareiron của p2r3

Bộ não của dự án: tạo địa hình không cần bộ nhớ

Vấn đề lớn nhất khi bạn muốn làm một máy chủ MC nhúng là tạo địa hình.

Trong Minecraft vanilla, thế giới được tạo bằng Perlin noise: nhiều lớp chồng lên nhau (octave), 6 tham số biome (nhiệt độ, độ ẩm, tính lục địa, xói mòn, weirdness, độ sâu), và cả một hệ thống caching để không phải tính toán lại mọi thứ mỗi lần.

Kết quả thật tuyệt đẹp. Nhưng nó tốn kém về tính toán, và chiếm RAM để lưu trữ các chunk đã tạo.

Cách tiếp cận của Bareiron hoàn toàn khác biệt. Thay vì chồng noise, nó sử dụng bilinear interpolation trên 4 điểm được tạo bởi một RNG tất định.

Bạn biết khi bạn phóng to một bức ảnh nhỏ bị pixel hóa và các cạnh trở nên mờ không? Đó chính xác là những gì đang xảy ra.

// worldgen.c, dòng 117-171 (đã đơn giản hóa)

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);
  // interpolation between 4 corners seeded by hash + biome
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

Nội suy song tuyến tính tiêu chuẩn: 4 góc, trọng số theo vị trí, một uint8_t duy nhất đầu ra. CHUNK_SIZE là 8, vì vậy nó được thực hiện bằng phép nhân số nguyên, không dùng float.

p2r3 trình bày từng bước trong video: đầu tiên là 4 góc của chunk, mỗi góc có một độ cao được seed bởi RNG.

4 góc của chunk, mỗi góc được seed bởi RNG tất định

Sau đó, phép nội suy giữa 4 điểm này tạo ra một bề mặt liên tục.

Áp dụng bilinear interpolation giữa 4 góc

Và bằng cách lặp lại mẫu này trên tất cả các chunk liền kề, chúng ta có được địa hình trải dài vô tận.

Kết quả cuối cùng: địa hình gồ ghề liên tục

RNG tất định

Chìa khóa làm nên tất cả điều này là seeding. Mỗi chunk có 4 góc, và mỗi góc cần một giá trị giả ngẫu nhiên duy nhất nhưng có thể tái tạo.

// worldgen.c, dòng 13-22

uint32_t getChunkHash (short x, short z) {
  uint8_t buf[8];
  memcpy(buf, &x, 2);          // 16 bit tọa độ X
  memcpy(buf + 2, &z, 2);      // 16 bit tọa độ Z
  memcpy(buf + 4, &world_seed, 4);  // 32 bit seed toàn cục
  return splitmix64(*((uint64_t *)buf));  // hash
}

Nó đóng gói 16 bit của X, 16 bit của Z, và 32 bit seed, vào một bộ đệm 8 byte, và đưa tất cả vào splitmix64. Kết quả: một giá trị tất định duy nhất cho mỗi vị trí, dựa trên seed của thế giới.

Bạn thấy sức mạnh của nó chứ? Máy chủ không cần lưu trữ địa hình. Nó tính toán lại khi cần khi người chơi đến khu vực mới, và cho kết quả hoàn toàn giống nhau mỗi lần.

splitmix64 được sử dụng là một PRNG cực nhanh được thiết kế cho hash 64 bit:

// worldgen.c (đã đơn giản hóa)

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 thao tác: cộng, xor/dịch, nhân, xor/dịch, nhân, xor/dịch. Không có lookup table, không có vòng lặp. Nó lấy bộ đệm 8 byte (X + Z + seed), xử lý như một số nguyên 64 bit, và trả về 32 bit hash. Tất định, nhanh, và gọn trong 5 dòng.

Tại sao đây không phải là Perlin noise

p2r3 tự nói trong video: "bạn càng thêm nhiều chữ số từ số ngẫu nhiên, địa hình càng trở nên đều đặn, giống như tung đồng xu nhiều lần càng tiến gần đến 50/50". Trong thực tế, đó là số bit của hash mà nó kết hợp:

// worldgen.c, dòng 51-115

// Với biome plains: 4 yếu tố kết hợp → địa hình đều đặn
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Với snowy plains: 2 yếu tố → gồ ghề hơn
h = (hash % 5) + ((hash >> 4) % 5);

Mỗi biome chọn bao nhiêu lần trích xuất bit để kết hợp. Càng nhiều, phân phối càng ổn định -- giống như tung nhiều đồng xu tiến gần đến 50/50. Càng ít, biến thể cục bộ càng mạnh.

Địa hình gồ ghề -- ít yếu tố, biến thể lớn

Chỉ với 2 yếu tố, snowy plains tạo ra địa hình đồi núi, gần như núi non. Các đỉnh và hố thường xuyên xuất hiện.

Địa hình đều đặn -- nhiều yếu tố, bề mặt mịn

Với 4 yếu tố, plains vẫn bằng phẳng và dễ đoán. Phân phối ổn định.

Một chunk được tạo trong 200 ms trên ESP32 -- so với thời gian không thể đo được trên cùng phần cứng với Perlin noise vì nó quá đắt.

Chi tiết đỉnh cao: truy vấn một khối mà không cần tạo toàn bộ chunk

Bạn chơi, bạn đào một khối. Máy chủ cần biết item nào để đưa cho bạn. Theo cách ngây thơ, bạn sẽ cần tạo toàn bộ chunk để làm điều đó.

Với bilinear interpolation, bạn có thể truy vấn bất kỳ điểm nào trên mặt phẳng trực tiếp từ tọa độ. Các góc của chunk có được từ vị trí người chơi, phép nội suy cho bạn độ cao tại bất kỳ offset nào. Một vài phép toán, không cần tạo chunk.

p2r3: "điều tôi muốn là một hàm kỳ diệu có thể cho tôi biết khối nào nằm ở tọa độ nhất định, mà không cần truy cập bộ nhớ hay tính toán bản đồ noise đắt đỏ". Chính xác những gì anh ấy đã làm.

Đây là cách độ cao trở thành các khối cụ thể:

// worldgen.c (đã đơn giản hóa)

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 điều kiện. Một lớp grass/dirt/stone/deepslate/bedrock. Khối bề mặt phụ thuộc vào biome qua biome_top[] -- grass cho plains, sand cho sa mạc. Không vòng lặp, không switch, một chuỗi if rơi vào đúng lớp.

Hang động, phép mirror lười biếng nhất

altitude_grotte = CAVE_BASE_DEPTH - (hauteur_surface - y);

Nó mirror độ cao bề mặt xuống lòng đất. Trông giống các hốc deepslate lớn. Không tính toán, một dòng.

Hang động được tạo bằng mirror địa hình bề mặt

Sơ đồ mirror địa hình để tạo hang động

Quặng, phiên bản XOR

candidat = (chunk_x ^ col_x ^ col_z) % 100;
if (candidat < 5 && y < 16) -> diamond

XOR tọa độ đảm bảo một ứng cử viên mỗi cột. Loại chỉ phụ thuộc vào độ cao. Kim cương được giấu dưới điểm thấp nhất của hang động để việc đào vẫn có ích.

Biome dạng tile map

Mỗi biome là một hòn đảo hình tròn trong một lưới, loại của nó được xác định bởi một mẫu tính toán từ seed. Có lưới, dễ đoán, và miễn phí.

Bản đồ biome dạng tile map -- mỗi đảo là một biome khác nhau

Mỗi biome có bộ tham số riêng được mã hóa trong các mảng:

// worldgen.c (đã đơn giản hóa)

static const uint8_t biome_base[] = {
  [BIOME_PLAINS]  = 48,   // độ cao cơ sở: 48
  [BIOME_DESERT]  = 52,   // cao hơn một chút
  [BIOME_FOREST]  = 50,   // ở giữa
  [BIOME_TAIGA]   = 46,   // thấp hơn một chút
  [BIOME_SNOWY]   = 40,   // thấp nhất
};

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 lần trích xuất → rất đều
  [BIOME_DESERT]  = 3,   // 3 lần trích xuất → vừa phải
  [BIOME_FOREST]  = 4,   // 4 lần trích xuất → đều, đồi núi
  [BIOME_TAIGA]   = 3,   // 3 lần trích xuất → vừa phải
  [BIOME_SNOWY]   = 2,   // 2 lần trích xuất → rất gồ ghề
};

Plains: độ cao 48, 4 yếu tố → địa hình rất bằng phẳng, cỏ.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Kết quả: dao động tối đa ±4 khối

Desert: độ cao 52, 3 yếu tố, khối bề mặt = cát. Không bao giờ dưới mực nước biển.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// Kết quả: dao động tối đa ±6 khối, kẹp ở SEA_LEVEL+1

Forest: độ cao 50, 4 yếu tố như plains nhưng cơ sở cao hơn → đồi cây cối.

Taiga: độ cao 46, 3 yếu tố → biến thể vừa phải, địa hình lạnh.

Snowy plains: độ cao 40, chỉ 2 yếu tố → gồ ghề nhất.

h = (hash % 5) + ((hash >> 4) % 5);
// Kết quả: dao động tối đa ±14 khối

Mỗi biome được mã hóa trong 3 mảng 5 phần tử: độ cao cơ sở, khối bề mặt, số yếu tố. Khi getHeightAtFromHash nhận được biome, nó tra cứu các mảng này để điều chỉnh địa hình. 15 byte dữ liệu để thay thế toàn bộ hệ thống biome của Minecraft.

Bộ phát hiện biome sử dụng seed để xác định biome nào tương ứng với mỗi chunk:

// worldgen.c (đã đơn giản hóa)

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];
}

Một mẫu 16 phần tử, một chỉ mục được seed bởi tọa độ chunk. Tạo ra một lưới lặp lại nhưng trực quan nhất quán. 4 dòng code để thay thế toàn bộ hệ thống tham số biome của Minecraft vanilla.

getHeightAtFromHash: trình lắp ráp địa hình

Hàm cốt lõi của quá trình tạo kết hợp 4 góc được seed bởi biome:

// worldgen.c (đã đơn giản hóa)

static uint8_t getHeightAtFromHash (int rx, int rz, short cx, short cz,
                                    uint32_t h, uint8_t biome) {
  // 4 góc trích từ hash, seed khác nhau mỗi góc
  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);

  // Ràng buộc biome: sa mạc không bao giờ dưới nước
  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);
  }

  // Nội suy từ 4 góc
  return interpolate(h1, h2, h3, h4, rx, rz);
}

Mỗi biome có một biome_base dịch chuyển độ cao tham chiếu, và 4 góc được trích xuất từ hash với các độ dịch khác nhau. Sa mạc buộc giá trị tối thiểu trên mực nước biển -- một dòng ràng buộc tránh nước mà không cần tính toán biom bổ sung.

Cây và xương rồng: đặt theo xác suất

Quá trình tạo bề mặt sử dụng cùng hash chunk để quyết định nơi đặt:

// worldgen.c (đã đơn giản hóa)

static void genFoliage (uint8_t *chunk_data, short cx, short cz,
                        uint32_t hash, uint8_t biome) {
  if (biome == BIOME_DESERT) {
    // Xương rồng: một ứng cử viên mỗi chunk, hash quyết định vị trí
    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 {
    // Cây: hash quyết định có đặt không và đặt ở đâu
    int tree_count = (hash & 3);  // 0-3 cây mỗi 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 cây mỗi chunk cho biome xanh, tối đa 1 xương rồng cho sa mạc. Hash chunk là nguồn entropy duy nhất -- & 7 cho vị trí trong chunk, & 3 cho bộ đếm. Mọi thứ đều tất định, không gì được lưu trữ.

generateChunk: ghép mọi thứ lại

Hàm kết hợp tất cả để tạo ra một chunk hoàn chỉnh gồm 8×8×256 khối:

// worldgen.c (đã đơn giản hóa)

void generateChunk (uint8_t *chunk, short cx, short cz) {
  uint32_t hash = getChunkHash(cx, cz);
  uint8_t biome = getChunkBiome(cx, cz);

  // Với mỗi cột trong chunk (8×8 = 64)
  for (int x = 0; x < 8; x ++) {
    for (int z = 0; z < 8; z ++) {
      // Tọa độ thế giới tuyệt đối
      int wx = cx * 8 + x;
      int wz = cz * 8 + z;

      // Độ cao của cột
      uint8_t height = getHeightAt(wx, wz);

      // Điền cột từ dưới lên
      for (int y = 0; y < height; y ++) {
        uint8_t block = getTerrainBlock(wx, y, wz);
        chunk[y * 64 + z * 8 + x] = block;
      }
    }
  }

  // Thêm các phần tử bề mặt (cây, xương rồng)
  genFoliage(chunk, cx, cz, hash, biome);
}

Đó là tất cả. 3 vòng lặp lồng nhau: với mỗi cột, tìm độ cao, điền khối, chuyển sang cột tiếp theo. Đầu ra là một uint8_t[16384] (8 × 8 × 256) đại diện cho chunk hoàn chỉnh. Không caching, không lazy loading, không nén -- chunk được tạo và gửi trực tiếp đến client.

Bộ nhớ: toàn mảng tĩnh

Kiến trúc bộ nhớ của Bareiron là C nhúng trong tất cả vinh quang của nó. Không malloc, không hash map, không danh sách liên kết.

Mọi thứ đều là mảng toàn cục kích thước cố định.

Các thay đổi khối

// globals.h, dòng 191-196

typedef struct {
  short x;      // 2 byte -- giới hạn 32 000 khối theo chiều ngang
  short z;      // 2 byte
  uint8_t y;    // 1 byte -- giới hạn 256 khối theo chiều dọc
  uint8_t block; // 1 byte -- giới hạn 256 loại khối
} BlockChange;

20 000 mục, tương đương khoảng 25 000 thay đổi -- tương đương một chunk rưỡi bị đào hết. Trường block có giá trị 0xFF đánh dấu mục trống. Việc tìm kiếm là quét tuyến tính:

Bố cục bộ nhớ của mảng khối -- 6 byte mỗi mục

// 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;
}

Thêm một thay đổi cũng trực tiếp như tìm kiếm:

```c
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 ++;
}

Một bộ đếm, một chỉ mục, một lần ghi. Không sắp xếp, không nén, không quản lý bộ nhớ. Khi mảng đầy, các thay đổi mới bị bỏ qua -- địa hình trở về trạng thái được tạo ban đầu.

Bình luận của tác giả về giới hạn 256 khối: "tôi không tính đến việc implement cầu thang đồng hơi bị patina đánh bóng sớm đâu."

Sinh vật: 8 byte mỗi con

// globals.h, dòng 240-251 (pragma pack(push, 1) để loại bỏ padding)

typedef struct {
  uint8_t type;   // 25=gà, 28=bò, 95=lợn, 106=cừu, 145=zombie
  short x;
  uint8_t y;      // nếu health=0, Y trở thành timer trước khi xóa
  short z;
  uint8_t data;   // bit 0-4: máu, bit 5: cừu bị cắt lông, bit 6-7: timer hoảng loạn
} MobData;

8 byte. Tối đa 16 vị trí. Không căn chỉnh, không padding. Byte data là một bitfield tự chế: 5 bit máu, 1 bit cắt lông, 2 bit timer hoảng loạn. Và khi một sinh vật chết, trường Y trở thành timer trước khi xóa. Tái sử dụng bộ nhớ ở cấp độ bit.

Người chơi: đóng gói chặt

Dữ liệu người chơi cũng dùng #pragma pack(push, 1) -- tọa độ ở dạng short + uint8_t, kho đồ trong mảng cố định uint16_t + uint8_t, và một trường flags mã hóa cả cooldown tấn công, trạng thái spawn, sneak, sprint, eat, load, movement cooldown, và khóa craft. Tất cả trong các bit riêng lẻ.

Vòng lặp chính: while(true) và non-blocking

Toàn bộ máy chủ chạy trên một vòng lặp, một thread, không thư viện event.

// main.c, dòng 594-720

while (true) {
  task_yield();  // để watchdog trên ESP32 thở

  // Chấp nhận kết nối mới (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;
  }

  // Tick máy chủ nếu đã hết thời gian
  if (get_program_time() - last_tick_time > TIME_BETWEEN_TICKS) {
    handleServerTick(time_since_last_tick);
    last_tick_time = get_program_time();
  }

  // Round-robin: một client, một packet mỗi lần lặp
  client_index = (client_index + 1) % MAX_PLAYERS;
  if (clients[client_index] == -1) continue;

  // Đọc header packet: 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);
}

Chỉ một client được xử lý mỗi lần lặp, và chỉ một packet được đọc mỗi lần. task_yield() ở đầu vòng lặp cho phép tác vụ idle của FreeRTOS thở trên ESP32 -- nếu không có nó, watchdog timer sẽ reset chip.

Việc phân phối packet là một switch khổng lồ 400 dòng:

// main.c, dòng 68-497

void handlePacket (int client_fd, int length, int packet_id, int state) {
  switch (packet_id) {
    case 0x00:  // Handshake / Status / Login tùy trạng thái
    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 (quản lý rương)
    case 0x19:  // Interact entity
    case 0x1D..0x20:  // Movement packets (case lớn nhất)
    case 0x28:  // Player action (đào/đặt)
    // ... hơn 40 case
  }
}

Không jump table động, không vtable, không map. Một switch được biên dịch thành jump table tĩnh. Hoàn hảo cho hệ thống nhúng.

Case 0x1D-0x20 là lớn nhất -- nó xử lý cập nhật vị trí, sát thương rơi, vượt biên giới chunk, spawn sinh vật, tạo chunk, VÀ cơn đói. Tất cả trong một fall-through lớn.

Mã nguồn máy chủ Bareiron -- 6800 dòng C

Server tick và AI của sinh vật

Hàm handleServerTick được gọi mỗi 50 ms (20 TPS). Nó quản lý thế giới trong khi vòng lặp chính xử lý người chơi:

// main.c (đã đơn giản hóa)

void handleServerTick (uint32_t delta) {
  // Cập nhật mỗi sinh vật
  for (int i = 0; i < MOB_COUNT; i ++) {
    if (mobs[i].type == 0 || mobs[i].data == 0) continue;  // chết hoặc rỗng

    MobData *mob = &mobs[i];
    int px, pz;
    getNearestPlayer(mob->x, mob->z, &px, &pz);

    if (mob->type == MOB_ZOMBIE) {
      // Thù địch: đi về phía người chơi gần nhất
      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 ++;
      // Sát thương tiếp xúc ở 2 khối
      if (abs(px - mob->x) <= 2 && abs(pz - mob->z) <= 2)
        damagePlayer(getNearestPlayerId(mob->x, mob->z), 3);
    } else {
      // Bị động: 8 hướng ngẫu nhiên
      uint8_t dir = getMobDir(mob);
      mob->x += dir_lookup[dir][0];
      mob->z += dir_lookup[dir][1];
      // Đổi hướng mỗi ~40 tick
      if (mob->data >> 6 < 1) setMobDir(mob, rand() & 7);
      mob->data = (mob->data & 0x3F) | ((mob->data - 0x40) & 0xC0);
    }

    // Đánh thức các chunk xung quanh sinh vật
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

AI của sinh vật thù địch là một phép so sánh tọa độ. Nghĩa đen là if (px < x) x--. Không pathfinding, không A*, không tránh vật cản. Zombie điều chỉnh X và Z độc lập về phía người chơi -- nó xuyên tường nếu có.

Sát thương tiếp xúc là 3 máu/giây. p2r3 cố tình đặt cao vì không có pathfinding khiến zombie dễ bị kiter.

Công thức giáp là công thức trước combat update -- đơn giản nhất có thể:

// main.c (đã đơn giản hóa)

static uint8_t applyArmor (uint8_t damage, uint16_t armor_value) {
  // Công thức tiền-1.9: giảm tuyến tính
  // Mỗi điểm giáp = 4% giảm, tối đa 80%
  uint8_t reduction = (armor_value * 4);
  if (reduction > 80) reduction = 80;
  return damage * (100 - reduction) / 100;
}

Full diamond = giảm 80%. Một đòn zombie 3 máu thành 0.6 máu. p2r3 chọn công thức cũ này vì nó tính được trong 2 phép toán -- không ngưỡng, không đường cong, chỉ là phần trăm tuyến tính.

Sinh vật bị động: 8 hướng trong một lookup table, đổi hướng mỗi ~40 tick. Trường data mã hóa hướng hiện tại trong 2 bit cao nhất, và timer đổi hướng trong 6 bit còn lại.

Sinh vật trong Bareiron -- zombie, lợn, cừu

Respawn sinh vật

Sinh vật không spawn bằng random tick. Chúng xuất hiện khi server tick gặp biên giới chunk mới:

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;
  }
}

Cùng RNG với địa hình, cùng seed chunk. Nếu một vị trí sinh vật trống, spawn là tất định.

Chế tạo: không ma trận, chỉ if/else

// crafting.c, dòng 9-347 (đã đơn giản hóa)

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // Nếu flag 0x80 được bật, bộ đệm craft đang được rương sử dụng
  if (player->flags & 0x80) { *count = 0; *item = 0; return; }

  // Đếm slot, tìm item đầu tiên, kiểm tra tính đồng nhất
  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:  /* ván gỗ, thỏi... */
    case 2:  /* gậy, kéo, đuốc */
    case 3:  /* xẻng, kiếm, slab */
    case 4:  /* bàn chế tạo, ủng */
    case 5:  /* cuốc, rìu, mũ */
    case 7:  /* quần, composteur */
    case 8:  /* lò nung, rương, giáp ngực */
    case 9:  /* khối đầy đủ (sắt, vàng, v.v.) */
  }
}

Kiểm tra đầu tiên: nếu flag 0x80 được bật, bộ đệm craft được tái sử dụng làm con trỏ rương. Không thể chế tạo.

Sau đó, nó đếm số slot đã điền, ghi nhận item đầu tiên, kiểm tra tính đồng nhất. Chỉ với điều đó, bạn match được lò nung trong 4 kiểm tra:

if (count == 8 && first == cobblestone && all_identical && center_empty)
    return furnace;

Đối với các hình dạng phức tạp, nó sử dụng chỉ mục của item đầu tiên và kiểm tra vị trí tương đối. Các công thức dùng chung một hàm matching -- vật liệu quyết định kết quả.

Giao diện chế tạo và rương trong Bareiron

Rương: hack thực sự

Hack bộ nhớ mà mọi người nói đến, trong code thực tế:

// procedures.c, dòng 1262-1293

if (target == B_chest) {
  // Tìm mục rương trong mảng khối
  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]);  // trỏ sau khối rương
    break;
  }
  if (storage_ptr == NULL) return;

  // Terrible memory hack!!
  // Sao chép CON TRỎ vào mảng item craft của người chơi
  memcpy(player->craft_items, &storage_ptr, sizeof(storage_ptr));
  player->flags |= 0x80;  // khóa craft

  // Gửi giao diện rương đến 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);
  }
}

Và bình luận trong code: // Terrible memory hack!!1!

Chính xác là vậy. Nó lấy địa chỉ bộ nhớ của mục tiếp theo trong block_changes[], sao chép nó vào player->craft_items (là uint16_t[9], tức 18 byte -- đủ để lưu một con trỏ 32 bit), và bật flag để không ai cố chế tạo trong thời gian đó.

Mỗi lần click trong kho đồ rương:

// packets.c, dòng 620-638

uint8_t *storage_ptr;
memcpy(&storage_ptr, player->craft_items, sizeof(storage_ptr));
// storage_ptr giờ trỏ đến dữ liệu rương
uint16_t *p_item = (uint16_t *)(storage_ptr + (slot - 41) * 3);
uint8_t *p_count = storage_ptr + (slot - 41) * 3 + 2;

Nó lấy lại con trỏ từ bộ đệm craft, và truy cập các slot với một offset. Dữ liệu rương được lưu với 3 byte mỗi slot (2 cho ID, 1 cho số lượng), dán liền nhau trong mảng khối.

Dữ liệu rương được lưu trong mảng khối -- một hack bộ nhớ

Cơn đói: 5 dòng thiên tài

// main.c, dòng 293-305

// Người chơi gửi packet di chuyển ~20/giây khi họ
// di chuyển, ít hơn nhiều khi đứng yên. Chúng ta tương quan
// điều này với hoạt động để mô phỏng cơn đói miễn phí.
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;
}

Chính xác là vậy. 5 dòng. Mỗi packet di chuyển giảm độ bão hòa. Khi độ bão hòa về 0, cơn đói giảm và ta reset độ bão hòa. Sprint (flag 0x08) nhân đôi tốc độ tiêu hao.

Không timer, không bộ nhớ cấp phát, không tính toán chuyên dụng. Một bộ đếm giảm dần trên các packet đã tồn tại.

Sát thương rơi

Hệ thống sát thương đơn giản nhất của dự án:

// Khi người chơi rời khỏi mặt đất, ta lưu Y của họ
// Khi họ chạm đất lại, ta trừ
sát_thương = y_cuối_cùng_trên_mặt_đất - y_hiện_tại;

Một phép trừ.

Đào và đặt khối

Khi bạn click vào một khối, packet 0x28 (Player Action) rơi vào switch. Handler phải xác định khối nào ở vị trí đó, loại bỏ nó, và đặt item vào kho đồ:

// main.c, case 0x28 (đã đơn giản hóa)

void handlePlayerAction (int client_fd, uint8_t action, int x, uint8_t y, int z) {
  switch (action) {
    case START_DESTROY_BLOCK: {
      // Xác định loại khối tại vị trí được click
      uint8_t block = getBlockAt(x, y, z);

      if (block == B_chest) {
        openChest(client_fd, x, y, z);
        break;
      }

      // Thêm vào block_changes
      addBlockChange(x, z, y, 0);  // 0 = air

      // Đưa item cho người chơi (trust the client)
      addItemToPlayer(client_fd, block_to_item(block), 1);

      // Gửi cập nhật đến client
      sc_blockChange(client_fd, x, y, z, 0);
      sc_ackBlockChange(client_fd, x, y, z, 0);
      break;
    }
    case PLACE_BLOCK: {
      // Đọc loại khối từ tay người chơi
      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 kết hợp cả tạo địa hình VÀ thay đổi của người chơi:

uint8_t getBlockAt (int x, uint8_t y, int z) {
  // Đầu tiên kiểm tra thay đổi của người chơi
  uint8_t change = getBlockChange(x, y, z);
  if (change != 0xFF) return change;

  // Nếu không, đọc từ địa hình được tạo
  return getTerrainBlock(x, y, z);
}

Ưu tiên thay đổi, fallback về địa hình. Không tranh luận, không cache, không overhead. Bên dưới getTerrainBlock là getHeightAt + các lớp stone/dirt/grass/coal.

Lò nung tức thì

Điều buồn cười nhất: lò nung không tồn tại như một thực thể. Nếu bạn đặt cobblestone vào ô "nấu" và coal vào "nhiên liệu", kết quả xuất hiện ngay lập tức. Không timer, không chunk ticking. Nó chỉ là một slot kho đồ trống khi bạn đặt đúng item.

Lò nung tức thì -- đặt nguyên liệu, kết quả ngay lập tức

Vòng lặp ESP32: máy chủ MC trong 4 KB stack

// main.c, dòng 732-779

#ifdef ESP_PLATFORM

void bareiron_main (void *pvParameters) {
  main();
  vTaskDelete(NULL);
}

static void wifi_event_handler (...) {
  if (/* đã kết nối */) {
    xTaskCreate(bareiron_main, "bareiron", 4096, NULL, 5, NULL);
  }
}

void app_main () {
  esp_timer_early_init();
  wifi_init();
  // Phần còn lại được xử lý bởi event handler
}
#endif

Toàn bộ máy chủ chạy trong một tác vụ FreeRTOS với 4096 byte stack. Đó là tất cả. Luồng main chính chỉ khởi tạo WiFi và đợi kết nối. Khi đã kết nối, nó spawn bareiron_main gọi hàm main() tiêu chuẩn.

Tất cả code dành riêng cho ESP32 được bảo vệ bởi #ifdef ESP_PLATFORM. Trên PC, tất cả biên dịch thành code POSIX tiêu chuẩn.

Những gì đã hy sinh

Để mọi thứ vừa vặn, có những tính năng vanilla không tồn tại:

  • Không nén mạng -- zlib quá đắt. Máy chủ tạo chunk nhanh, nhưng gửi chúng là nút thắt cổ chai.
  • Không random tick -- cây mọc bằng bone meal hoặc không. Sinh vật spawn ở biên giới chunk.
  • Không item entity -- khối đào được đưa thẳng vào kho đồ. Hoạt ảnh chỉ mang tính thị giác.
  • Không kiểm tra kho đồ -- trust the client. 64 kim cương? OK. Một chunk đào trong 1 giây? OK. Dùng giữa những người tin tưởng lẫn nhau.
  • Không ánh sáng máy chủ -- đuốc được gửi sau mọi thứ, client tự tính.
  • Không chất lỏng dần dần -- trạng thái cuối cùng tức thì.

Kết quả cuối cùng

Ryzen 5 3600: ~0.5 ms mỗi chunk. ESP32-C3 giá 1$: ~200 ms mỗi chunk. Có thể chơi được.

Benchmark tạo chunk -- Ryzen vs ESP32

3+ người chơi: bắt đầu giật. Có thể so sánh với 2b2t vào giờ cao điểm, theo lời tác giả.

Nhiều người chơi kết nối cùng một máy chủ Bareiron

Triết lý

p2r3: "Tôi chỉ thích ý tưởng rằng con chip nhỏ bé giá 1$ này tiêu thụ 0.5 Watt có thể chạy một thứ tiên tiến như Minecraft. Science isn't about 'why', it's about 'why not'."

Mỗi dòng là một sự đánh đổi:

  • Perlin noise → nội suy: kém đẹp hơn, nhanh gấp 200 lần, tốn 0 bộ nhớ
  • Ma trận chế tạo → matching hardcode: code bẩn, tốn 0 byte
  • zlib → không: kết nối chậm = chết, nhưng chơi được
  • Xác thực → trust: zero bảo mật, zero tính toán

Mỗi tính năng vắng mặt cho phép một tính năng khác tồn tại trong giới hạn phần cứng.

3 điều cần nhớ:

  1. Nội suy + RNG -- 4 điểm được seed, địa hình vô tận, không lưu trữ, truy vấn không cần tạo lại chunk, 200 ms tạo. Đó là nước cờ thiên tài khiến mọi thứ khác trở nên khả thi.
  2. Mỗi tính năng đều có cái giá -- Không nén, không random tick, không xác thực. Đó không phải là sơ suất, đó là điều cho phép mọi thứ vừa vặn trong 520 KB.
  3. Những hack bẩn thỉu nhất là thông minh nhất -- Rương trong mảng khối qua memcpy, cơn đói qua packet di chuyển, lò nung tức thì. Giải pháp sạch sẽ sẽ quá đắt.

Nếu dự án này làm bạn quan tâm, mọi thứ đều có trên GitHub theo GPLv3. Đó là C rất bẩn, và tôi hiếm khi đọc mã nguồn nào vui đến thế xD

Bareiron -- เซิร์ฟเวอร์ Minecraft ที่รันบนไมโครคอนโทรลเลอร์ราคา 1$

โค้ด C 6800 บรรทัด, zero malloc, Perlin noise ถูกแทนที่ด้วย bilinear

บทนำ

คุณเคยสงสัยไหมว่าเราสามารถรันเซิร์ฟเวอร์ Minecraft บนไมโครคอนโทรลเลอร์ราคา 1 ดอลลาร์ได้หรือไม่?

ผมเคย. และคำตอบคือใช่. ตามตัวอักษรเลย.

มีโปรเจกต์ชื่อ Bareiron โดย p2r3 และนี่น่าจะเป็นหนึ่งในโปรเจกต์ที่น่าทึ่งที่สุดที่ผมเคยเห็นในโลก Minecraft ช่วงไม่กี่ปีที่ผ่านมา เรากำลังพูดถึงไบนารีที่ขนาดแค่ 300 กิโลไบต์, โค้ด C 6800 บรรทัด, ไม่มี dependency ภายนอก, ไม่มี malloc, ไม่มี threading, และมันรันบน ESP32 ราคา 1 ดอลลาร์

ESP32-C3 ไมโครคอนโทรลเลอร์ที่รันเซิร์ฟเวอร์นี้

สร้าง terrain ไม่จำกัด. มีไบโอม. มีถ้ำ. มีคราฟต์. มีขุด. มีม็อบ. มีหิว. มีหีบ. ทุกอย่างที่คุณคาดหวังจากเซิร์ฟเวอร์ survival

บนชิปที่กินไฟแค่ 0.5 วัตต์ และมีสัญญาณนาฬิกา 160 MHz

เพื่อให้เห็นภาพ: เซิร์ฟเวอร์ Minecraft vanilla ต้องใช้ RAM หลายกิกะไบต์. ESP32-C3 มี SRAM 520 KB (เหลือ 400 หลังบูต). โปรเซสเซอร์เมื่อ 20 ปีที่แล้วก็รันที่ระดับกิกะเฮิรตซ์แล้ว -- ตัวนี้สูงสุดที่ 160 MHz. ปัจจัยด้านพลังบริสุทธิ์ระหว่างสองอย่างคือประมาณ 20,000

p2r3 ไม่ได้แค่เขียนเซิร์ฟเวอร์ Minecraft ในภาษา C, เขาได้ reinvent ทุกส่วนประกอบของเซิร์ฟเวอร์เพื่อให้มันอยู่ในข้อจำกัดเหล่านี้ เราจะมาดูกันว่าทำยังไง โดยการเปิดซอร์สโค้ด

ภาพขนาดย่อของวิดีโอนำเสนอ Bareiron โดย p2r3

สมองของโปรเจกต์: การสร้าง terrain โดยไม่ใช้หน่วยความจำ

ปัญหาที่ใหญ่ที่สุดเมื่อคุณต้องการทำเซิร์ฟเวอร์ MC แบบ embedded คือการสร้าง terrain

ใน Minecraft vanilla, โลกถูกสร้างด้วย Perlin noise: หลายชั้นซ้อนกัน (octaves), พารามิเตอร์ไบโอม 6 ตัว (temperature, humidity, continentalness, erosion, weirdness, depth), และระบบ caching ทั้งหมดเพื่อไม่ต้องคำนวณซ้ำทุกครั้ง

ผลลัพธ์สวยงามมาก. แต่มันแพงในแง่การคำนวณ และกิน RAM เพื่อเก็บ chunks ที่สร้างแล้ว

แนวทางของ Bareiron แตกต่างอย่างสิ้นเชิง. แทนที่จะซ้อน noise, มันใช้ bilinear interpolation บน 4 จุดที่สร้างโดย RNG แบบ deterministic

คุณรู้ไหมเวลาเราขยายภาพเล็ก ๆ ที่เป็นพิกเซลแล้วขอบภาพเบลอ? นั่นแหละครับ

// 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);
  // interpolation between 4 corners seeded by hash + biome
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

การ interpolate แบบ bilinear มาตรฐาน: 4 มุม, น้ำหนักตามตำแหน่ง, ได้ uint8_t ตัวเดียว. CHUNK_SIZE คือ 8, ดังนั้นใช้การคูณจำนวนเต็ม ไม่มี float

p2r3 แสดงให้เห็นทีละขั้นตอนในวิดีโอ: เริ่มจาก 4 มุมของ chunk แต่ละมุมมีความสูงที่ seed โดย RNG

4 มุมของ chunk แต่ละมุม seed โดย RNG แบบ deterministic

จากนั้น interpolation ระหว่าง 4 จุดนี้สร้างพื้นผิวที่ต่อเนื่อง

การใช้ bilinear interpolation ระหว่าง 4 มุม

และเมื่อทำซ้ำ pattern นี้บนทุก chunk ที่อยู่ติดกัน เราจะได้ terrain ที่ขยายออกไปไม่มีที่สิ้นสุด

ผลลัพธ์สุดท้าย: terrain ไม่สม่ำเสมอต่อเนื่อง

RNG แบบ deterministic

กุญแจสำคัญที่ทำให้ทุกอย่างเป็นไปได้คือการ seeding. แต่ละ chunk มี 4 มุม และแต่ละมุมต้องการค่า pseudo-random ที่ไม่ซ้ำกันแต่สามารถสร้างซ้ำได้

// worldgen.c, lines 13-22

uint32_t getChunkHash (short x, short z) {
  uint8_t buf[8];
  memcpy(buf, &x, 2);          // 16 bits of coordinate X
  memcpy(buf + 2, &z, 2);      // 16 bits of coordinate Z
  memcpy(buf + 4, &world_seed, 4);  // 32 bits of global seed
  return splitmix64(*((uint64_t *)buf));  // hash
}

มัน pack 16 bits ของ X, 16 bits ของ Z, และ 32 bits ของ seed ลงใน buffer ขนาด 8 bytes แล้วส่งทั้งหมดเข้า splitmix64. ผลลัพธ์: ค่า deterministic ที่ไม่ซ้ำกันสำหรับแต่ละตำแหน่ง โดยขึ้นอยู่กับ seed ของโลก

คุณเห็นพลังของมันไหม? เซิร์ฟเวอร์ไม่ต้องเก็บ terrain. มันคำนวณใหม่แบบทันทีเมื่อผู้เล่นมาถึงพื้นที่ใหม่ และให้ผลลัพธ์เดิมทุกครั้ง

splitmix64 ที่ใช้เป็น prng ที่เร็วมากออกแบบมาสำหรับ hash 64 บิต:

// 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, multiplication, xor/shift, multiplication, xor/shift. ไม่มี lookup table, ไม่มี loop. มันรับ buffer 8 bytes (X + Z + seed) แล้ว treat เป็น integer 64 บิต แล้วคืนค่า hash 32 บิต. มัน deterministic, เร็ว, และอยู่ใน 5 บรรทัด

ทำไมถึงไม่ใช่ Perlin noise

p2r3 พูดเองในวิดีโอ: "ยิ่งคุณเพิ่ม digits ของ random number มากเท่าไหร่ terrain ก็ยิ่งสม่ำเสมอมากขึ้น เหมือนกับการโยนเหรียญมากขึ้นที่เข้าใกล้ 50/50" ในทางปฏิบัติ มันคือจำนวน bits ของ hash ที่นำมารวมกัน:

// worldgen.c, lines 51-115

// For plains biome: 4 factors combined → regular terrain
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// For snowy plains: 2 factors → more rugged
h = (hash % 5) + ((hash >> 4) % 5);

แต่ละไบโอมเลือกว่าจะรวมกี่ bit extraction. ยิ่งมาก การกระจายยิ่งเสถียร -- เหมือนการโยนเหรียญมากขึ้นที่เข้าใกล้ 50/50. ยิ่งน้อย การแปรผันเฉพาะที่ยิ่งสูง

Terrain ไม่สม่ำเสมอ -- ปัจจัยน้อย, การแปรผันสูง

ด้วยแค่ 2 ปัจจัย, snowy plains สร้าง terrain เป็นลูกคลื่น เกือบเป็นภูเขา. ยอดเขาและหุบเขาพบบ่อย

Terrain สม่ำเสมอ -- หลายปัจจัย, พื้นผิวเรียบ

ด้วย 4 ปัจจัย, ที่ราบเรียบและคาดเดาได้. การกระจายตัวเสถียร

หนึ่ง chunk ใช้เวลา 200 ms บน ESP32 -- เทียบกับเวลาที่วัดไม่ได้บนฮาร์ดแวร์เดียวกันด้วย Perlin noise เพราะมันแพงมาก

รายละเอียดที่เจ๋ง: สอบถามบล็อกโดยไม่ต้องสร้างทั้ง chunk

คุณเล่น, คุณขุดบล็อก. เซิร์ฟเวอร์ต้องรู้ว่าควรให้ item อะไรคุณ. ตามปกติ คุณต้องสร้างทั้ง chunk เพื่อการนั้น

ด้วย bilinear interpolation, คุณสามารถสอบถาม จุดใดก็ได้ บนระนาบโดยตรงจากพิกัด. มุมของ chunk หาได้จากตำแหน่งผู้เล่น, interpolation ให้ความสูงที่ offset ใด ๆ. แค่คณิตศาสตร์ไม่กี่ operation, ไม่ต้องสร้าง chunk

p2r3: "สิ่งที่ฉันต้องการคือฟังก์ชันมหัศจรรย์ที่สามารถบอกฉันได้ว่าบล็อกอะไรอยู่ที่พิกัดที่กำหนด โดยไม่ต้องเข้าถึงหน่วยความจำหรือคำนวณ noise maps ที่แพง" และนั่นคือสิ่งที่เขาทำ

นี่คือวิธีที่ความสูงกลายเป็นบล็อกจริง:

// 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. ชั้น grass/dirt/stone/deepslate/bedrock. บล็อกพื้นผิวขึ้นอยู่กับไบโอมผ่าน biome_top[] -- grass สำหรับที่ราบ, sand สำหรับทะเลทราย. ไม่มี loop, ไม่มี switch, เป็น cascade ของ if ที่ตกสู่ชั้นที่ถูกต้อง

ถ้ำ: mirror ที่ขี้เกียจที่สุด

cave_altitude = CAVE_BASE_DEPTH - (surface_height - y);

มัน mirror ความสูงของผิวดินใต้ดิน. มันดูเหมือนโพรง deepslate ขนาดใหญ่. ไม่ต้องคำนวณ, แค่บรรทัดเดียว

ถ้ำที่สร้างโดย mirror ของ terrain ผิวดิน

แผนภาพ mirror ของ terrain เพื่อสร้างถ้ำ

แร่: แบบ XOR

candidate = (chunk_x ^ col_x ^ col_z) % 100;
if (candidate < 5 && y < 16) -> diamond

XOR ของพิกัดรับประกันว่าหนึ่ง candidate ต่อคอลัมน์. ประเภทขึ้นอยู่กับระดับความสูง. เพชรซ่อนอยู่ใต้จุดต่ำสุดของถ้ำเพื่อให้การขุดยังมีประโยชน์

ไบโอมแบบ tile map

แต่ละไบโอมเป็นเกาะวงกลมในกริด, ประเภทของมันถูกกำหนดโดย pattern ที่คำนวณจาก seed. เป็นกริด, คาดเดาได้, และฟรี

แผนที่ไบโอมแบบ tile map -- แต่ละเกาะคือไบโอมที่แตกต่าง

แต่ละไบโอมมีชุดพารามิเตอร์ของตัวเองที่เข้ารหัสในอาร์เรย์:

// 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,   // slightly 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 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 rugged
};

Plains: ความสูง 48, 4 ปัจจัย → terrain เรียบมาก, หญ้า

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Result: max ±4 blocks variation

Desert: ความสูง 52, 3 ปัจจัย, บล็อกพื้นผิว = ทราย. ไม่เคยต่ำกว่าระดับน้ำทะเล

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// Result: max ±6 blocks variation, clamped to SEA_LEVEL+1

Forest: ความสูง 50, 4 ปัจจัยเหมือน plains แต่ฐานสูงกว่า → เนินเขาที่มีป่าไม้

Taiga: ความสูง 46, 3 ปัจจัย → การแปรผันปานกลาง, terrain เย็น

Snowy plains: ความสูง 40, แค่ 2 ปัจจัย → ขรุขระที่สุด

h = (hash % 5) + ((hash >> 4) % 5);
// Result: max ±14 blocks variation

แต่ละไบโอมถูกเข้ารหัสใน 3 อาร์เรย์ 5 รายการ: ความสูงพื้นฐาน, บล็อกพื้นผิว, จำนวนปัจจัย. เมื่อ getHeightAtFromHash ได้รับไบโอม, มันจะค้นหาในอาร์เรย์เหล่านี้เพื่อปรับ terrain. ข้อมูล 15 bytes เพื่อแทนที่ระบบไบโอมทั้งหมดของ Minecraft

ตัวตรวจจับไบโอมใช้ seed เพื่อกำหนดว่าไบโอมใดตรงกับ 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];
}

pattern 16 รายการ, index ที่ seed โดยพิกัด chunk. มันให้กริดที่ซ้ำกันแต่ดูสอดคล้องทางสายตา. 4 บรรทัดของโค้ดเพื่อแทนที่ระบบพารามิเตอร์ไบโอมทั้งหมดของ Minecraft vanilla

getHeightAtFromHash: ประกอบ terrain

ฟังก์ชันหลักของการสร้างที่รวม 4 มุมที่ seed ด้วยไบโอม:

// 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);
  }

  // Interpolation from 4 corners
  return interpolate(h1, h2, h3, h4, rx, rz);
}

แต่ละไบโอมมี biome_base ที่เลื่อนความสูงอ้างอิง และ 4 มุมถูกสกัดจาก hash ด้วย offset ต่างกัน. ทะเลทรายบังคับค่าต่ำสุดให้สูงกว่าระดับน้ำทะเล -- ข้อจำกัดบรรทัดเดียวที่หลีกเลี่ยงน้ำโดยไม่ต้องคำนวณไบโอมเพิ่มเติม

ต้นไม้และกระบองเพชร: การวางแบบความน่าจะเป็น

การสร้างพื้นผิวใช้ hash เดียวกับ chunk เพื่อตัดสินใจว่าจะปลูกที่ไหน:

// 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 if and where to place them
    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 ต่อ chunk สำหรับไบโอมสีเขียว, กระบองเพชรสูงสุด 1 สำหรับทะเลทราย. hash ของ chunk เป็นแหล่ง entropy เพียงแหล่งเดียว -- & 7 สำหรับตำแหน่งใน chunk, & 3 สำหรับตัวนับ. ทุกอย่าง deterministic, ไม่มีอะไรถูกเก็บ

generateChunk: ประกอบทุกอย่างเข้าด้วยกัน

ฟังก์ชันที่รวบรวมทุกอย่างเพื่อสร้าง chunk เต็มขนาด 8×8×256 บล็อก:

// 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 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 from bottom up
      for (int y = 0; y < height; y ++) {
        uint8_t block = getTerrainBlock(wx, y, wz);
        chunk[y * 64 + z * 8 + x] = block;
      }
    }
  }

  // Add surface features (trees, cacti)
  genFoliage(chunk, cx, cz, hash, biome);
}

แค่นั้น. 3 loops ซ้อน: สำหรับแต่ละคอลัมน์, หาความสูง, เติมบล็อก, ไปคอลัมน์ถัดไป. ผลลัพธ์คือ uint8_t[16384] (8 × 8 × 256) แทน chunk ที่สมบูรณ์. ไม่มี caching, ไม่มี lazy loading, ไม่มีการบีบอัด -- chunk ถูกสร้างและส่งตรงไปยัง client

ที่เก็บข้อมูล: arrays แบบคงที่ทุกที่

สถาปัตยกรรมหน่วยความจำของ Bareiron คือ C แบบ embedded อย่างเต็มรูปแบบ. ไม่มี malloc, ไม่มี hash maps, ไม่มี linked lists

ทุกอย่างอยู่ในอาร์เรย์ global ขนาดคงที่

การเปลี่ยนแปลงของบล็อก

// globals.h, lines 191-196

typedef struct {
  short x;      // 2 bytes -- limited to 32,000 blocks horizontally
  short z;      // 2 bytes
  uint8_t y;    // 1 byte -- limited to 256 blocks vertically
  uint8_t block; // 1 byte -- limited to 256 block types
} BlockChange;

20,000 รายการ, ประมาณ 25,000 การเปลี่ยนแปลง -- เทียบเท่ากับ chunk ครึ่ง chunk ที่ถูกขุดจนหมด. ฟิลด์ block ที่ 0xFF หมายถึงรายการว่าง. การค้นหาเป็น linear scan:

เค้าโครงหน่วยความจำของอาร์เรย์บล็อก -- 6 bytes ต่อรายการ

// 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 straightforward as searching:
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 ++;
}

ตัวนับ, index, เขียน. ไม่มีการจัดเรียง, ไม่มีการ compact, ไม่มีการจัดการหน่วยความจำ. เมื่ออาร์เรย์เต็ม, การเปลี่ยนแปลงใหม่จะถูก ignored -- terrain กลับสู่สถานะที่สร้างไว้

คอมเมนต์ของผู้เขียนเกี่ยวกับขีดจำกัด 256 บล็อก: "ฉันยังไม่คิดจะ implement บันไดทองแดงที่ถูกขัดเงาเล็กน้อยในเร็ว ๆ นี้"

ม็อบ: 8 bytes ต่อหัว

// globals.h, lines 240-251 (pragma pack(push, 1) to eliminate 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 timer before removal
  short z;
  uint8_t data;   // bits 0-4: health, bit 5: sheep sheared, bits 6-7: panic timer
} MobData;

8 bytes. สูงสุด 16 ตำแหน่ง. ไม่มีการจัด alignment, ไม่มี padding. byte data เป็น bitfield ทำเอง: 5 bits สำหรับ HP, 1 bit สำหรับถูกตัดขน, 2 bits สำหรับ panic timer. และเมื่อม็อบตาย, ฟิลด์ Y กลายเป็น timer ก่อนถูกลบ. การใช้หน่วยความจำซ้ำในระดับ bit

ผู้เล่น: แพ็คแน่น

ข้อมูลผู้เล่นใช้ #pragma pack(push, 1) เช่นกัน -- พิกัดเป็น short + uint8_t, ช่อง inventory เป็นอาร์เรย์คงที่ของ uint16_t + uint8_t, และฟิลด์ flags เข้ารหัสทั้ง attack cooldown, สถานะ spawn, sneak, sprint, eat, load, movement cooldown, และ craft lock ทุกอย่างอยู่ใน bits แต่ละตัว

ลูปหลัก: while(true) และ non-blocking

เซิร์ฟเวอร์ทั้งหมดรันบนลูปเดียว, หนึ่งเธรด, ไม่มี event library

// main.c, lines 594-720

while (true) {
  task_yield();  // let the watchdog breathe on ESP32

  // Accept 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 has 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);
}

client เดียวถูกจัดการต่อ iteration ของลูป และอ่านครั้งละหนึ่ง packet. task_yield() ที่ต้นลูปช่วยให้ FreeRTOS idle task หายใจบน ESP32 -- ถ้าไม่มี, watchdog timer จะรีเซ็ตชิป

การ dispatch packets เป็น switch ขนาดใหญ่ 400 บรรทัด:

// 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
  }
}

ไม่มี jump table แบบไดนามิก, ไม่มี vtable, ไม่มี map. switch ถูก compile เป็น jump table แบบ static. เหมาะสำหรับ embedded

case 0x1D-0x20 ใหญ่ที่สุด -- จัดการ position updates, fall damage, การข้ามขอบเขต chunk, mob spawn, chunk generation, และความหิว. ทุกอย่างใน fall-through อันใหญ่เดียว

โค้ดของเซิร์ฟเวอร์ Bareiron -- C 6800 บรรทัด

Server tick และ AI ของม็อบ

ฟังก์ชัน handleServerTick ถูกเรียกทุก 50 ms (20 TPS). มันจัดการโลกในขณะที่ลูปหลักจัดการผู้เล่น:

// 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: walks 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 up chunks around the mob
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

AI ของม็อบศัตรูคือการเปรียบเทียบพิกัด. ตามตัวอักษร if (px < x) x--. ไม่มี pathfinding, ไม่มี A*, ไม่มี obstacle avoidance. ซอมบี้ปรับ X และ Z แยกกันเข้าหาผู้เล่น -- มันเดินทะลุกำแพงถ้ามี

contact damage อยู่ที่ 3 หัวใจ/วินาที. p2r3 ตั้งให้สูงเพราะการไม่มี pathfinding ทำให้ซอมบี้หลอกง่าย

สูตรเกราะเป็นแบบก่อน combat update -- ง่ายที่สุดเท่าที่จะเป็นไปได้:

// 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%. ซอมบี้ตี 3 หัวใจกลายเป็น 0.6 หัวใจ. p2r3 เลือกสูตรเก่าเพราะคำนวณแค่ 2 operations -- ไม่มี thresholds, ไม่มี curves, แค่เปอร์เซ็นต์เชิงเส้น

ม็อบ passive: 8 ทิศทางใน lookup table, เปลี่ยนทิศทุก ~40 ticks. ฟิลด์ data เข้ารหัสทิศทางปัจจุบันใน 2 bits บน และ timer การเปลี่ยนทิศทางใน 6 bits ที่เหลือ

ม็อบใน Bareiron -- ซอมบี้, หมู, แกะ

การเกิดใหม่ของม็อบ

ม็อบไม่ได้เกิดด้วย random ticks. พวกมันปรากฏเมื่อ server tick พบขอบเขต chunk ใหม่:

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;
  }
}

RNG เดียวกับ terrain, seed chunk เดียวกัน. ถ้าตำแหน่งม็อบว่าง, การเกิดเป็น deterministic

คราฟต์: ไม่มี 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 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.) */
  }
}

check แรก: ถ้า flag 0x80 ถูกตั้ง, buffer คราฟต์ถูก reuse เป็น pointer ของหีบ. ไม่สามารถคราฟต์ได้

จากนั้นนับช่องที่เติม, จด item แรก, ตรวจสอบความเหมือน. แค่เท่านี้ก็ match เตาเผาใน 4 checks:

if (count == 8 && first == cobblestone && all_identical && center_empty)
    return furnace;

สำหรับรูปทรงที่ซับซ้อน, มันใช้ index ของ item แรกและตรวจสอบตำแหน่งสัมพัทธ์. สูตรคราฟต์ใช้ฟังก์ชัน matching เดียวกัน -- วัสดุกำหนดผลลัพธ์

อินเทอร์เฟซคราฟต์และหีบใน Bareiron

หีบ: แฮกของจริง

แฮกหน่วยความจำที่ทุกคนพูดถึง, ในโค้ดจริง:

// 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!!
  // Copy the POINTER into the player's craft items array
  memcpy(player->craft_items, &storage_ptr, sizeof(storage_ptr));
  player->flags |= 0x80;  // lock crafting

  // Send chest interface to 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);
  }
}

และคอมเมนต์ในโค้ด: // Terrible memory hack!!1!

มันคือสิ่งนั้นจริง ๆ. มันเอาที่อยู่หน่วยความจำของรายการถัดไปใน block_changes[] คัดลอกไปยัง player->craft_items (ซึ่งเป็น uint16_t[9] ดังนั้น 18 bytes -- พอที่จะเก็บ pointer 32 บิต) และตั้ง flag เพื่อไม่ให้ใครพยายามคราฟต์ในช่วงเวลานั้น

ในทุกคลิกใน 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;

มันดึง pointer จาก buffer คราฟต์ และเข้าถึงช่องด้วย offset. ข้อมูลหีบถูกเก็บที่ 3 bytes ต่อช่อง (2 สำหรับ ID, 1 สำหรับจำนวน), วางติดกันในอาร์เรย์บล็อก

ข้อมูลหีบที่เก็บในอาร์เรย์บล็อก -- แฮกหน่วยความจำ

ความหิว: 5 บรรทัดแห่งอัจฉริยภาพ

// main.c, lines 293-305

// Players send movement packets at ~20/sec when moving,
// much less when standing still. We correlate this
// 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;
}

มันคือสิ่งนั้นจริง ๆ. 5 บรรทัด. ทุก packet การเคลื่อนไหวลด saturation. เมื่อ saturation ถึงศูนย์, ความหิวลดลงและ saturation ถูกรีเซ็ต. การ sprint (flag 0x08) ทำให้ drain เป็นสองเท่า

ไม่มี timer, ไม่มีหน่วยความจำที่จัดสรร, ไม่มีการคำนวณเฉพาะ. แค่ตัวนับที่ลดลงบน packets ที่มีอยู่แล้ว

Fall damage

ระบบ damage ที่ง่ายที่สุดในโปรเจกต์:

// When player leaves the ground, store their Y
// When they touch the ground again, subtract
damage = last_y_on_ground - current_y;

การลบครั้งเดียว.

ขุดและวางบล็อก

เมื่อคุณคลิกที่บล็อก, packet 0x28 (Player Action) ตกลงใน switch. handler ต้องระบุว่าบล็อกอะไรอยู่ที่ตำแหน่งนั้น, เอาออก, และใส่ item ใน 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 block type at 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 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: {
      // Read block type from 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 รวมการสร้าง terrain และการเปลี่ยนแปลงของผู้เล่น:

uint8_t getBlockAt (int x, uint8_t y, int z) {
  // First check player changes
  uint8_t change = getBlockChange(x, y, z);
  if (change != 0xFF) return change;

  // Otherwise, read from generated terrain
  return getTerrainBlock(x, y, z);
}

การเปลี่ยนแปลงได้ Priority, fallback เป็น terrain. ไม่มีการถกเถียง, ไม่มี cache, ไม่มี overhead. เบื้องหลัง getTerrainBlock คือ getHeightAt + ชั้น stone/dirt/grass/coal

เตาเผาแบบทันที

ที่ตลกที่สุด: เตาเผาไม่มี existence ในฐานะ entity. ถ้าคุณใส่ cobblestone ในช่อง "ปรุง" และ coal ใน "เชื้อเพลิง", ผลลัพธ์จะปรากฏทันที. ไม่มี timer, ไม่มี chunk ticking. มันแค่ช่อง inventory ที่ว่างเปล่าเมื่อคุณใส่อันที่ถูกต้อง

เตาเผาแบบทันที -- วางวัตถุดิบ, ผลลัพธ์ทันที

ลูป ESP32: เซิร์ฟเวอร์ MC ใน stack 4 KB

// 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

เซิร์ฟเวอร์ทั้งหมดรันใน task FreeRTOS ด้วย stack 4096 bytes. เท่านั้น. main thread หลักแค่ initialize WiFi และรอการเชื่อมต่อ. เมื่อเชื่อมต่อ, มัน spawn bareiron_main ที่เรียก main() มาตรฐาน

โค้ดเฉพาะ ESP32 ทั้งหมดถูกป้องกันด้วย #ifdef ESP_PLATFORM. บน PC, ทั้งหมด compile เป็นโค้ด POSIX มาตรฐาน

สิ่งที่ถูก sacrifice

เพื่อให้ทุกอย่างอยู่ได้, มีฟีเจอร์ vanilla ที่ไม่มี:

  • ไม่มีการบีบอัดเครือข่าย -- zlib แพงเกินไป. เซิร์ฟเวอร์สร้าง chunks เร็ว, แต่การส่งคือ bottleneck
  • ไม่มี random ticks -- ต้นไม้โตด้วย bone meal หรือไม่ก็ไม่โต. ม็อบเกิดที่ขอบเขต chunk
  • ไม่มี item entities -- บล็อกที่ขุดไปที่ inventory โดยตรง. แอนิเมชัน purely visual
  • ไม่มีการตรวจสอบ inventory -- trust the client. เพชร 64 อัน? OK. ขุด chunk หมดใน 1 วินาที? OK. ใช้ระหว่างคนที่ไว้ใจกัน
  • ไม่มี server-side light -- คบเพลิงถูกส่งหลังจากทุกอย่างอื่น, client คำนวณ
  • ไม่มีของเหลวแบบค่อยเป็นค่อยไป -- สถานะสุดท้ายทันที

ผลลัพธ์สุดท้าย

Ryzen 5 3600: ~0.5 ms ต่อ chunk ESP32-C3 ราคา 1$: ~200 ms ต่อ chunk. เล่นได้

Benchmark การสร้าง chunk -- Ryzen vs ESP32

3+ ผู้เล่น: กระตุก. เทียบเท่า 2b2t ในชั่วโมงเร่งด่วน ตามที่ผู้เขียนบอก

ผู้เล่นหลายคนเชื่อมต่อกับเซิร์ฟเวอร์ Bareiron เดียวกัน

ปรัชญา

p2r3: "ฉันแค่ชอบความคิดที่ว่าชิปจิ๋วราคา 1 ดอลลาร์ที่กินไฟ 0.5 วัตต์นี้สามารถรันอะไรที่ล้ำหน้าเท่า Minecraft ได้ Science isn't about 'why', it's about 'why not'."

ทุกบรรทัดคือ tradeoff:

  • Perlin noise → interpolation: ดูไม่สวยเท่า, เร็วขึ้น 200x, ไม่ใช้หน่วยความจำ
  • คราฟต์ matrices → matching แบบ hardcode: โค้ดน่าเกลียด, ไม่ใช้ byte เลย
  • zlib → ไม่มี: connection ห่วย = ตาย, แต่เล่นได้
  • Validation → trust: ไม่มีความปลอดภัย, ไม่มีการคำนวณ

ทุกฟีเจอร์ที่หายไปทำให้อีกฟีเจอร์หนึ่งมีอยู่ได้ภายใต้ข้อจำกัดของฮาร์ดแวร์

3 สิ่งที่ควรจำ:

  1. Interpolation + RNG -- 4 จุดที่ seed, terrain ไม่จำกัด, ไม่มีการเก็บ, query โดยไม่ต้องสร้าง chunk ใหม่, สร้าง 200 ms. นี่คือ move อัจฉริยะที่ทำให้ทุกอย่างอื่นเป็นไปได้
  2. ทุกฟีเจอร์มีต้นทุน -- ไม่มีการบีบอัด, ไม่มี random ticks, ไม่มีการตรวจสอบ. นี่ไม่ใช่การลืม, แต่มันคือสิ่งที่ทำให้ทุกอย่างอยู่ใน 520 KB
  3. แฮกที่ดูน่าเกลียดคือสิ่งที่ฉลาดที่สุด -- หีบในอาร์เรย์บล็อกผ่าน memcpy, ความหิวผ่าน movement packets, เตาเผาแบบทันที. วิธีที่สะอาดคงแพงเกินไป

ถ้าคุณสนใจโปรเจกต์นี้, ทุกอย่างอยู่บน GitHub ในลิขสิทธิ์ GPLv3. มันเป็น C ที่สกปรกมาก และผมไม่ค่อยสนุกกับการอ่านซอร์สโค้ดเท่านี้มาก่อน xD

Related Articles