GitHub avatar

Fox's Blog

Bareiron -- the Minecraft Server Running on a $1 Microcontroller

6800 lines of C, zero malloc, Perlin noise replaced with

Introduction

Running a Minecraft server on a one-dollar microcontroller sounds like a joke.

I asked the question, and the answer is yes. Literally.

There's a project called Bareiron, by p2r3, and it's probably one of the most fascinating projects I've seen in the Minecraft world in recent years. We're talking a binary fitting in 300 kilobytes, 6800 lines of C, zero external dependencies, no malloc, no threading, and it runs on a one-dollar ESP32.

ESP32-C3, the microcontroller running the server

Infinite terrain generation. Biomes. Caves. Crafting. Mining. Mobs. Hunger. Chests. Everything you expect from a survival server.

On a chip consuming 0.5W with a 160MHz clock.

To give an order of magnitude: a vanilla Minecraft server needs several gigs of RAM. The ESP32-C3 is 520KB of SRAM (400K usable after boot). CPUs 20 years ago already ran in gigahertz -- this one tops out at 160MHz. The raw power factor between the two is about 20,000.

p2r3 didn't write a Minecraft server in C, he reinvented every server brick to fit those constraints. Let's look at how, by opening the source code.

Thumbnail of p2r3's Bareiron intro video

The project's brain: memoryless terrain generation

The biggest problem when you want an embedded MC server is terrain generation.

In vanilla Minecraft, the world generates with Perlin noise: several stacked layers (octaves), 6 biome parameters (temperature, humidity, continentalness, erosion, weirdness, depth), and a whole caching system to avoid recomputing everything each time.

The result is gorgeous. But it's expensive in compute, and it takes RAM to store generated chunks.

Bareiron's approach is radically different. Instead of stacking noise, it uses bilinear interpolation on 4 points generated by a deterministic RNG.

You know when you upscale a small pixelated image and the edges get blurry? That's exactly it.

// worldgen.c, lines 117-171 (simplified)

uint8_t interpolate (uint8_t a, uint8_t b, uint8_t c, uint8_t d, int x, int z) {
  uint16_t top    = a * (CHUNK_SIZE - x) + b * x;
  uint16_t bottom = c * (CHUNK_SIZE - x) + d * x;
  return (top * (CHUNK_SIZE - z) + bottom * z) / (CHUNK_SIZE * CHUNK_SIZE);
}

uint8_t getHeightAt (int x, int z) {
  int _x = floor(x / CHUNK_SIZE);  // chunk coordinates
  int _z = floor(z / CHUNK_SIZE);
  int rx = x % CHUNK_SIZE;          // offset inside chunk
  int rz = z % CHUNK_SIZE;
  uint32_t hash = getChunkHash(_x, _z);
  uint8_t biome = getChunkBiome(_x, _z);
  // interpolate between 4 corners seeded by hash + biome
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

Standard bilinear interpolation: 4 corners, position-based weights, a single uint8_t out. CHUNK_SIZE is 8, so it's all integer multiplication, no floats.

p2r3 shows it step by step in the video: first the chunk's 4 corners, each with an RNG-seeded height.

The chunk's 4 corners, each seeded by the deterministic RNG

Then interpolating between those 4 points creates a continuous surface.

Applying bilinear interpolation between the 4 corners

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

Final result: continuous uneven terrain

The deterministic RNG

The key making all of this possible is seeding. Each chunk has 4 corners, and each corner needs a unique but reproducible pseudo-random value.

// worldgen.c, lines 13-22

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

It packs 16 bits of X, 16 bits of Z, and 32 bits of seed into an 8-byte buffer, and runs it all through splitmix64. Result: a unique deterministic value per position, based on the world seed.

You catch the power of the mechanism? The server doesn't need to store terrain. It recomputes on the fly when the player arrives in a new zone, and it gives exactly the same result every time.

The splitmix64 used is an ultra-fast PRNG designed for 64-bit hashes:

// worldgen.c (simplified)

static uint32_t splitmix64 (uint64_t state) {
  state += 0x9E3779B97F4A7C15ull;
  uint64_t z = state;
  z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ull;
  z = (z ^ (z >> 27)) * 0x94D049BB133111EBull;
  return (z ^ (z >> 31)) >> 32;
}

3 ops: addition, xor/shift, multiplication, xor/shift, multiplication, xor/shift. All in direct ops, no table or loop. It takes the 8-byte buffer (X + Z + seed), treats it as a 64-bit int, and returns 32 bits of hash. Deterministic, fast, fits in 5 lines.

Why it's not Perlin noise

p2r3 says it himself in the video: "the more digits of the random number you add, the more regular the terrain gets, like more coin flips getting you closer to 50/50". In practice, it's the number of hash bits he combines:

// worldgen.c, lines 51-115

// For a plains biome: 4 combined factors → regular terrain
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// For snowy plains: 2 factors → rougher
h = (hash % 5) + ((hash >> 4) % 5);

Each biome picks how many bit extractions it combines. The more there are, the more the distribution stabilizes -- like more coin flips approaching 50/50. The fewer, the stronger local variations.

Uneven terrain -- few factors, strong variations

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

Regular terrain -- multiple factors, smooth surface

With 4 factors, plains stay flat and predictable. The distribution stabilizes.

One chunk generates in 200ms on ESP32 -- versus unmeasurable time on the same hardware with Perlin noise, it's that expensive.

The killer detail: querying a block without generating the whole chunk

You play, you mine a block. The server must know which item to give you. Naively, it would need to generate the whole chunk for that.

With bilinear interpolation, you query any point of the plane directly from coords. The chunk corners come from the player position, interpolation gives the height at any offset. A handful of math ops, no chunk generation.

p2r3: "what I want is a magic function that can tell me which block sits at a given coordinate, without accessing memory or computing expensive noise maps". Exactly what he built.

Here's how height becomes concrete blocks:

// worldgen.c (simplified)

uint8_t getTerrainBlock (int x, uint8_t y, int z) {
  uint8_t surface = getHeightAt(x, z);

  if (y > surface)             return B_air;
  if (y == surface)            return biome_top[getChunkBiome(x, z)];
  if (y > surface - 4)         return B_dirt;
  if (y > surface - 16)        return B_stone;
  if (y > CAVE_BASE_DEPTH)     return B_deepslate;
                               return B_bedrock;
}

5 conditions. One grass/dirt/stone/deepslate/bedrock layer. The surface block depends on the biome via biome_top[] -- grass for plains, sand for desert. A simple cascade of ifs falls into the right layer, replacing loop and switch.

Caves, the laziest mirror

cave_altitude = CAVE_BASE_DEPTH - (surface_height - y);

It mirrors surface height underground. That looks like big deepslate cavities. Zero compute, one line.

Caves generated by mirroring surface terrain

Diagram of terrain mirroring for cave generation

Ores, XOR version

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

One XOR of coords guarantees one candidate per column. Type depends just on altitude. Diamonds hide under the caves' lowest point to keep digging useful.

Tile-map biomes

Each biome is a circular island in a grid, its type set by a seed-computed pattern. Gridded, predictable, and free.

Tile-map biomes -- each island is a different biome

Each biome has its own parameter set encoded in arrays:

// worldgen.c (simplified)

static const uint8_t biome_base[] = {
  [BIOME_PLAINS]  = 48,   // base height: 48
  [BIOME_DESERT]  = 52,   // slightly higher
  [BIOME_FOREST]  = 50,   // in between
  [BIOME_TAIGA]   = 46,   // a bit lower
  [BIOME_SNOWY]   = 40,   // the lowest
};

static const uint8_t biome_top[] = {
  [BIOME_PLAINS]  = B_grass,
  [BIOME_DESERT]  = B_sand,
  [BIOME_FOREST]  = B_grass,
  [BIOME_TAIGA]   = B_grass,
  [BIOME_SNOWY]   = B_snow_block,
};

static const uint8_t biome_factors[] = {
  [BIOME_PLAINS]  = 4,   // 4 extractions → very regular
  [BIOME_DESERT]  = 3,   // 3 extractions → moderate
  [BIOME_FOREST]  = 4,   // 4 extractions → regular, hilly
  [BIOME_TAIGA]   = 3,   // 3 extractions → moderate
  [BIOME_SNOWY]   = 2,   // 2 extractions → very rough
};

Plains: height 48, 4 factors → very flat terrain, grass.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Result: ±4 blocks max variation

Desert: height 52, 3 factors, surface block = sand. Never below sea level.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// Result: ±6 blocks max variation, clamped to SEA_LEVEL+1

Forest: height 50, 4 factors like plains but higher base → wooded hills.

Taiga: height 46, 3 factors → moderate variations, cold terrain.

Snowy plains: height 40, only 2 factors → the roughest.

h = (hash % 5) + ((hash >> 4) % 5);
// Result: ±14 blocks max variation

Each biome encodes in 3 arrays of 5 entries: base height, surface block, factor count. When getHeightAtFromHash gets the biome, it consults those tables to tune terrain. 15 bytes of data replacing Minecraft's whole biome system.

The biome detector uses the seed to determine which biome matches each chunk:

// worldgen.c (simplified)

static const uint8_t biome_pattern[] = {
  BIOME_PLAINS, BIOME_FOREST, BIOME_PLAINS, BIOME_DESERT,
  BIOME_FOREST, BIOME_TAIGA,  BIOME_PLAINS, BIOME_SNOWY,
  BIOME_PLAINS, BIOME_FOREST, BIOME_DESERT,  BIOME_PLAINS,
  BIOME_SNOWY,  BIOME_PLAINS, BIOME_FOREST, BIOME_TAIGA,
};

uint8_t getChunkBiome (short cx, short cz) {
  uint32_t h = splitmix64(cx * 31 + cz * 97 + world_seed);
  uint8_t index = h % 16;
  return biome_pattern[index];
}

A 16-entry pattern, an index seeded by chunk coords. That gives a repetitive but visually coherent grid. 4 lines of code replacing all of vanilla Minecraft's biome parameter system.

getHeightAtFromHash: the terrain assembler

The function at the heart of generation combining the 4 biome-seeded corners:

// worldgen.c (simplified)

static uint8_t getHeightAtFromHash (int rx, int rz, short cx, short cz,
                                    uint32_t h, uint8_t biome) {
  // 4 corners extracted from hash, different seed per corner
  uint8_t h1 = biome_base[biome] + (h & 0x0F);
  uint8_t h2 = biome_base[biome] + ((h >> 4) & 0x0F);
  uint8_t h3 = biome_base[biome] + ((h >> 8) & 0x0F);
  uint8_t h4 = biome_base[biome] + ((h >> 12) & 0x0F);

  // Biome constraint: desert never underwater
  if (biome == BIOME_DESERT) {
    h1 = max(h1, SEA_LEVEL + 1);
    h2 = max(h2, SEA_LEVEL + 1);
    h3 = max(h3, SEA_LEVEL + 1);
    h4 = max(h4, SEA_LEVEL + 1);
  }

  // Interpolate from the 4 corners
  return interpolate(h1, h2, h3, h4, rx, rz);
}

Each biome has a biome_base shifting the reference height, and the 4 corners extract from the hash with different shifts. The desert forces the min above sea level -- one constraint line avoiding water with no extra biome compute.

Trees and cacti: probabilistic placement

Surface generation uses the same chunk hash to decide where to plant:

// worldgen.c (simplified)

static void genFoliage (uint8_t *chunk_data, short cx, short cz,
                        uint32_t hash, uint8_t biome) {
  if (biome == BIOME_DESERT) {
    // Cactus: one candidate per chunk, hash determines position
    int tx = (hash >> 8) & 7;
    int tz = (hash >> 12) & 7;
    int ty = getHeightAt(cx * 8 + tx, cz * 8 + tz);
    if (chunk_data[ty * 64 + tz * 8 + tx] == B_sand)
      placeCactus(chunk_data, tx, ty + 1, tz);
  } else {
    // Trees: hash determines whether to place and where
    int tree_count = (hash & 3);  // 0-3 trees per chunk
    for (int i = 0; i < tree_count; i ++) {
      int tx = ((hash >> (4 + i * 4)) & 7);
      int tz = ((hash >> (6 + i * 4)) & 7);
      int ty = getHeightAt(cx * 8 + tx, cz * 8 + tz);
      placeTree(chunk_data, tx, ty + 1, tz);
    }
  }
}

0-3 trees per chunk for green biomes, 1 cactus max for desert. The chunk hash is the only entropy source -- an & 7 for position in the chunk, an & 3 for the counter. All deterministic, nothing stored.

generateChunk: putting it all together

The function assembling everything to produce a full 8×8×256 chunk:

// worldgen.c (simplified)

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

  // For each column of the chunk (8×8 = 64)
  for (int x = 0; x < 8; x ++) {
    for (int z = 0; z < 8; z ++) {
      // Absolute world coordinates
      int wx = cx * 8 + x;
      int wz = cz * 8 + z;

      // Column height
      uint8_t height = getHeightAt(wx, wz);

      // Fill the column bottom to top
      for (int y = 0; y < height; y ++) {
        uint8_t block = getTerrainBlock(wx, y, wz);
        chunk[y * 64 + z * 8 + x] = block;
      }
    }
  }

  // Add surface elements (trees, cacti)
  genFoliage(chunk, cx, cz, hash, biome);
}

That's it. 3 nested loops: for each column, find the height, fill the blocks, move to the next. Output is a uint8_t[16384] (8 × 8 × 256) representing the full chunk. The chunk generates then ships straight to the client, with no caching, lazy loading, or compression.

Storage: static arrays everywhere

Bareiron's memory architecture is embedded C in all its glory: only fixed-size global arrays, no malloc, no hash maps, no linked lists.

Block changes

// globals.h, lines 191-196

typedef struct {
  short x;      // 2 bytes -- capped at 32,000 blocks horizontal
  short z;      // 2 bytes
  uint8_t y;    // 1 byte -- capped at 256 blocks vertical
  uint8_t block; // 1 byte -- capped at 256 block types
} BlockChange;

20,000 entries, about 25,000 changes -- the equivalent of a chunk and a half fully dug out. The block field at 0xFF marks a free entry. Lookup is a linear scan:

Memory layout of the block array -- 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;  // skip chest data
    #endif
  }
  return 0xFF;
}

Adding a change is as direct as lookup:

static uint8_t changes_count = 0;

void addBlockChange (short x, short z, uint8_t y, uint8_t block) {
  if (changes_count >= MAX_CHANGES) return;
  block_changes[changes_count].x = x;
  block_changes[changes_count].z = z;
  block_changes[changes_count].y = y;
  block_changes[changes_count].block = block;
  changes_count ++;
}

One counter, one index, one write. The array just piles up: no sorting, no compaction, no memory management. When the array is full, new changes are ignored -- terrain reverts to its generated state.

The author's comment on the 256-block cap: "I don't plan to implement lightly weathered waxed copper stairs anytime soon."

Mobs: 8 bytes per pipe head

// globals.h, lines 240-251 (pragma pack(push, 1) to kill padding)

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

8 bytes. 16 slots max. Alignment and padding thrown out. The data byte is a homemade bitfield: 5 bits of health, 1 shear bit, 2 panic-timer bits. And when a mob dies, the Y field becomes a deletion timer. Memory reuse down to the bit.

Players: tightly packed

Player data uses #pragma pack(push, 1) too -- coords in short + uint8_t, inventories in fixed uint16_t + uint8_t arrays, and a flags field encoding attack cooldown, spawn state, sneak, sprint, eat, load, movement cooldown, and craft lock, all in individual bits.

The main loop: while(true) and non-blocking

The whole server runs on one loop, one thread, zero event library.

// main.c, lines 594-720

while (true) {
  task_yield();  // let the FreeRTOS idle task breathe on ESP32

  // Accept a new connection (non-blocking)
  for (int i = 0; i < MAX_PLAYERS; i ++) {
    if (clients[i] != -1) continue;
    clients[i] = accept(server_fd, ...);
    if (clients[i] != -1) client_count ++;
    break;
  }

  // Server tick if time elapsed
  if (get_program_time() - last_tick_time > TIME_BETWEEN_TICKS) {
    handleServerTick(time_since_last_tick);
    last_tick_time = get_program_time();
  }

  // Round-robin: one client, one packet per iteration
  client_index = (client_index + 1) % MAX_PLAYERS;
  if (clients[client_index] == -1) continue;

  // Read packet header: length + ID
  recv(client_fd, &recv_buffer, 2, MSG_PEEK);
  int length = readVarInt(client_fd);
  int packet_id = readVarInt(client_fd);
  handlePacket(client_fd, length - sizeVarInt(packet_id), packet_id, state);
}

One client handled per loop iteration, one packet read at a time. The task_yield() at the loop start lets the FreeRTOS idle task breathe on ESP32 -- without it, the watchdog timer resets the chip.

Packet dispatch is a monstrous 400-line switch:

// main.c, lines 68-497

void handlePacket (int client_fd, int length, int packet_id, int state) {
  switch (packet_id) {
    case 0x00:  // Handshake / Status / Login depending on state
    case 0x01:  // Status ping
    case 0x02:  // Plugin message
    case 0x03:  // Login/configuration acknowledgment
    case 0x08:  // Chat
    case 0x0B:  // Client status (respawn)
    case 0x11:  // Click container (handles chests)
    case 0x19:  // Interact entity
    case 0x1D..0x20:  // Movement packets (biggest case)
    case 0x28:  // Player action (dig/place)
    // ... 40+ cases
  }
}

A switch compiles to a static jump table, replacing dynamic jump table, vtable, and map. Perfect for embedded.

The 0x1D-0x20 case is the biggest -- it handles position updates, fall damage, chunk-border crossings, mob spawning, chunk generation, AND hunger. All in one big fall-through.

The Bareiron server code -- 6800 lines of C

The server tick and mob AI

The handleServerTick function runs every 50ms (20 TPS). It manages the world while the main loop handles players:

// main.c (simplified)

void handleServerTick (uint32_t delta) {
  // Update each mob
  for (int i = 0; i < MOB_COUNT; i ++) {
    if (mobs[i].type == 0 || mobs[i].data == 0) continue;  // dead or empty

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

    if (mob->type == MOB_ZOMBIE) {
      // Hostile: walk toward nearest player
      if (px < mob->x) mob->x --;
      else if (px > mob->x) mob->x ++;
      if (pz < mob->z) mob->z --;
      else if (pz > mob->z) mob->z ++;
      // Contact damage at 2 blocks
      if (abs(px - mob->x) <= 2 && abs(pz - mob->z) <= 2)
        damagePlayer(getNearestPlayerId(mob->x, mob->z), 3);
    } else {
      // Passive: 8 random directions
      uint8_t dir = getMobDir(mob);
      mob->x += dir_lookup[dir][0];
      mob->z += dir_lookup[dir][1];
      // Direction change every ~40 ticks
      if (mob->data >> 6 < 1) setMobDir(mob, rand() & 7);
      mob->data = (mob->data & 0x3F) | ((mob->data - 0x40) & 0xC0);
    }

    // Wake chunks around the mob
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

Hostile mob AI is a coordinate comparison. Literally if (px < x) x--. The zombie ignores pathfinding, A*, and obstacle avoidance: it adjusts X and Z independently toward the player -- it walks through walls if there are any.

Contact damage is 3 hearts/sec. p2r3 wanted it high because the lack of pathfinding makes zombies easy to kite.

The armor formula is the pre-combat-update one -- the simplest possible:

// main.c (simplified)

static uint8_t applyArmor (uint8_t damage, uint16_t armor_value) {
  // Pre-1.9 formula: linear reduction
  // Each armor point = 4% reduction, max 80%
  uint8_t reduction = (armor_value * 4);
  if (reduction > 80) reduction = 80;
  return damage * (100 - reduction) / 100;
}

Full diamond = 80% reduction. A zombie hit at 3 hearts becomes 0.6 hearts. p2r3 picked that old formula because it computes in 2 ops -- no thresholds, no curves, just a linear percentage.

Passive mobs: 8 directions in a lookup table, heading change every ~40 ticks. The data field encodes the current direction in the 2 high bits, and the direction-change timer in the 6 remaining bits.

Mobs in Bareiron -- zombies, pigs, sheep

Mob respawning

Mobs don't spawn on random ticks. They appear when the server tick meets a new chunk border:

if (player crossed chunk boundary) {
  for (int i = 0; i < MOB_COUNT; i ++) {
    if (mobs[i].type != 0) continue;
    spawnMob(&mobs[i], new_chunk_coords, getChunkHash(cx, cz));
    break;
  }
}

Same RNG as terrain, same chunk seed. If a mob slot is free, spawning is deterministic.

Crafting: no matrices, if/else

// crafting.c, lines 9-347 (simplified)

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // If flag 0x80 is set, the craft buffer is used by a chest
  if (player->flags & 0x80) { *count = 0; *item = 0; return; }

  // Count filled slots, find first item, check identity
  uint8_t filled = 0, first = 10, identical = true;
  for (int i = 0; i < 9; i ++) {
    if (player->craft_items[i]) {
      filled ++;
      if (first == 10) first = i;
      else if (player->craft_items[i] != player->craft_items[first])
        identical = false;
    }
  }

  switch (filled) {
    case 1:  /* planks, ingots... */
    case 2:  /* sticks, shears, torches */
    case 3:  /* shovels, swords, slabs */
    case 4:  /* crafting table, boots */
    case 5:  /* pickaxes, axes, helmets */
    case 7:  /* leggings, composters */
    case 8:  /* furnace, chest, chestplate */
    case 9:  /* full blocks (iron, gold, etc.) */
  }
}

First check: if the 0x80 flag is set, the craft buffer is recycled as a chest pointer. Craft stays locked.

Then it counts filled slots, notes the first item, checks identity. With just that, you match the furnace in 4 checks:

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

For complex shapes, it uses the first item's index and checks relative position. Recipes share one matching function -- material determines the result.

Crafting and chest UI in Bareiron

Chests: the real hack

The memory hack everyone talks about, in real code:

// procedures.c, lines 1262-1293

if (target == B_chest) {
  // Find the chest entry in the block array
  uint8_t *storage_ptr = NULL;
  for (int i = 0; i < block_changes_count; i ++) {
    if (block_changes[i].block != B_chest) continue;
    if (block_changes[i].x != x || block_changes[i].y != y || block_changes[i].z != z)
      continue;
    storage_ptr = (uint8_t *)(&block_changes[i + 1]);  // point after the chest block
    break;
  }
  if (storage_ptr == NULL) return;

  // Terrible memory hack!!
  // We copy the POINTER into the player's craft item array
  memcpy(player->craft_items, &storage_ptr, sizeof(storage_ptr));
  player->flags |= 0x80;  // lock crafting

  // Send the chest UI to the client
  sc_openScreen(player->client_fd, 2, "Chest", 5);
  for (int i = 0; i < 27; i ++) {
    uint16_t item;
    uint8_t count;
    memcpy(&item, storage_ptr + i * 3, 2);
    memcpy(&count, storage_ptr + i * 3 + 2, 1);
    sc_setContainerSlot(player->client_fd, 2, i, count, item);
  }
}

And the comment in the code: // Terrible memory hack!!1!

Exactly that. It takes the memory address of the next entry in block_changes[], copies it into player->craft_items (a uint16_t[9], so 18 bytes -- enough to hold a 32-bit pointer), and raises the flag so nobody tries crafting meanwhile.

On each click in the chest inventory:

// packets.c, lines 620-638

uint8_t *storage_ptr;
memcpy(&storage_ptr, player->craft_items, sizeof(storage_ptr));
// storage_ptr now points to the chest data
uint16_t *p_item = (uint16_t *)(storage_ptr + (slot - 41) * 3);
uint8_t *p_count = storage_ptr + (slot - 41) * 3 + 2;

It pulls the pointer back from the craft buffer, and accesses slots with an offset. Chest data sits at 3 bytes per slot (2 for ID, 1 for count), stuck together in the block array.

Chest data stored in the block array -- a memory hack

Hunger: 5 lines of genius

// main.c, lines 293-305

// Players send movement packets at ~20/sec when
// moving, far fewer when still. We correlate that
// with activity to simulate hunger for free.
if (player->saturation == 0) {
  if (player->hunger > 0) player->hunger--;
  player->saturation = 200;
  sc_setHealth(client_fd, player->health, player->hunger, player->saturation);
} else if (player->flags & 0x08) {  // sprinting
  player->saturation -= 1;
}
}

Literally that. 5 lines. Each movement packet decrements saturation. When saturation hits zero, hunger drops and saturation resets. Sprint (flag 0x08) doubles the drain.

Zero timers, zero allocated memory, zero dedicated compute. A counter decrementing on packets that already exist.

Fall damage

The project's simplest damage system:

damage = last_ground_y - current_y

One subtraction.

Mining and placing blocks

When you click a block, packet 0x28 (Player Action) lands in the switch. The handler must figure out which block sits at the position, remove it, and put the item in inventory:

// main.c, case 0x28 (simplified)

void handlePlayerAction (int client_fd, uint8_t action, int x, uint8_t y, int z) {
  switch (action) {
    case START_DESTROY_BLOCK: {
      // Determine the block type at the clicked position
      uint8_t block = getBlockAt(x, y, z);

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

      // Add to block_changes
      addBlockChange(x, z, y, 0);  // 0 = air

      // Give the item to the player (trust the client)
      addItemToPlayer(client_fd, block_to_item(block), 1);

      // Send the update to the client
      sc_blockChange(client_fd, x, y, z, 0);
      sc_ackBlockChange(client_fd, x, y, z, 0);
      break;
    }
    case PLACE_BLOCK: {
      // Read the block type from the player's hand
      uint16_t item = getHeldItem(client_fd);
      uint8_t block = item_to_block(item);
      addBlockChange(x, z, y, block);
      removeItemFromPlayer(client_fd, item, 1);
      sc_blockChange(client_fd, x, y, z, block);
      break;
    }
  }
}

getBlockAt combines generated terrain AND player changes:

uint8_t getBlockAt (int x, uint8_t y, int z) {
  // Check player changes first
  uint8_t change = getBlockChange(x, y, z);
  if (change != 0xFF) return change;

  // Otherwise, read from generated terrain
  return getTerrainBlock(x, y, z);
}

Changes first, generated fallback. Zero debate, zero cache, zero overhead. Under the hood getTerrainBlock is getHeightAt + the stone/dirt/grass/coal layers.

The instant furnace

The funniest: the furnace doesn't exist as an entity. If you put cobblestone in the "cooking" slot and coal in "fuel", the result appears immediately, no timer or chunk ticking: it's just an inventory slot emptying when you put the right items in.

Instant furnace -- place ingredients, instant result

The ESP32 loop: an MC server in 4KB of stack

// main.c, lines 732-779

#ifdef ESP_PLATFORM

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

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

void app_main () {
  esp_timer_early_init();
  wifi_init();
  // The rest is handled by the event handler
}
#endif

The whole server runs in a FreeRTOS task with 4096 bytes of stack. That's it. The main thread just initializes WiFi and waits for a connection. Once connected, it spawns bareiron_main calling standard main().

All ESP32-specific code is guarded by #ifdef ESP_PLATFORM. On PC, all of it compiles to standard POSIX code.

What got sacrificed

For all of that to fit, vanilla features that don't exist:

  • Zero network compression -- zlib too expensive. The server generates chunks fast, but sending them is the bottleneck.
  • Zero random ticks -- trees grow with bone meal or not. Mobs spawn at chunk borders.
  • Zero item entities -- mined blocks go straight to inventory. The animation is purely visual.
  • No inventory validation -- trust the client. 64 diamonds? OK. A chunk mined in 1 sec? OK. For use among trusted people.
  • Zero server-side light -- torches sent after everything else, the client computes.
  • Zero progressive fluids -- instant final state.

The final result

Ryzen 5 3600: ~0.5ms per chunk. $1 ESP32-C3: ~200ms per chunk. Playable.

Chunk generation benchmark -- Ryzen vs ESP32

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

Several players connected to the same Bareiron server

The philosophy

p2r3: "I just love the idea that this tiny $1 chip consuming 0.5W can run something as advanced as Minecraft. Science isn't about 'why', it's about 'why not'."

Every line is a tradeoff:

  • Perlin noise → interpolation: less pretty, 200x faster, zero memory
  • Craft matrices → hardcoded matching: ugly code, zero bytes
  • zlib → nothing: bad connection = death, but playable
  • Validation → trust: zero security, zero compute

Every missing feature lets another exist within hardware limits.

The 3 points to remember:

  1. Interpolation + RNG -- 4 seeded points, infinite terrain, zero storage, query without regenerating the chunk, 200ms generation. It's the genius move making everything else possible.
  2. Every feature has a cost -- No compression, random ticks, or validation is a deliberate choice: it's what fits in 520KB.
  3. Nasty hacks are the smartest -- Chests in the block array via memcpy, hunger via movement packets, instant furnace. The clean solution would have been too expensive.

If the project interests you, it's all on GitHub under GPLv3. It's nasty C, and I rarely had that much fun reading source code.

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

Faire tourner un serveur Minecraft sur un microcontrôleur à 1 dollar, ça ressemble à une blague.

Je me suis posé la question, 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 mécanisme ? 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. Le tout tient en opérations directes, sans table ni 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. Une simple cascade de if tombe dans la bonne couche, ce qui remplace boucle et switch.

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. Le chunk est généré puis envoyé direct au client, sans passer par du caching, du lazy loading ou de la compression.

Le stockage : des arrays statiques partout

L'architecture mémoire de Bareiron, c'est du C embarqué dans toute sa splendeur : seuls des tableaux globaux de taille fixe, ni malloc, ni hash maps, ni listes chaînées.

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 :

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. Le tableau se contente d'empiler : aucun tri, aucune compaction, aucune 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. L'alignement et le padding passent à la trappe. 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
  }
}

Un switch compile en jump table statique, ce qui remplace jump table dynamique, vtable et map. 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--. Le zombie ignore pathfinding, A* et obstacle avoidance : il 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. Le craft reste verrouillé.

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 :

// 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, sans timer ni 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 :

  • Zéro compression réseau -- zlib trop cher. Le serveur génère des chunks vite, mais les envoyer est le bottleneck.
  • Zéro random ticks -- les arbres poussent avec de la bone meal ou pas. Les mobs spawnent aux frontières de chunk.
  • Zéro entité 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.
  • Zéro lumière serveur -- les torches sont envoyées après tout le reste, le client calcule.
  • Zéro 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 points à 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 -- L'absence de compression, de random ticks et de validation relève du choix assumé : 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.

Bareiron,在 1 美元微控制器上跑的 Minecraft 服务器

6800 行 C,零 malloc,Perlin 噪声换成双线性插值,tilemap 生物群系,全跑在 1 美元的芯片上。

引言

在 1 美元微控制器上跑 Minecraft 服务器,听着像笑话。

我问了自己行不行,答案是行。字面意义的行。

有个项目叫 Bareiron,作者 p2r3,大概是我这几年在 Minecraft 世界见过最迷人的项目之一。二进制 300KB,6800 行 C,零外部依赖,不 malloc,不开线程,跑在 1 美元的 ESP32 上。

ESP32-C3,跑服务器的微控制器

无限地形生成。生物群系。洞穴。合成。挖矿。生物。饥饿。箱子。你对生存服务器期望的一切都有。

芯片功耗 0.5 瓦,主频 160 MHz。

给个量级:vanilla Minecraft 服务器要几个 G 内存。ESP32-C3 是 520 KB SRAM(启动后剩 400)。20 年前的处理器都跑 GHz 了,这个 160 MHz 封顶。纯算力差约 20000 倍。

p2r3 没写个 C 版 Minecraft 服务器,他把服务器的每个砖都重发明了一遍,塞进这些约束。下面打开源码看他怎么干的。

p2r3 的 Bareiron 介绍视频缩略图

项目的大脑:无内存地形生成

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

vanilla Minecraft 用 Perlin 噪声生成世界:多层叠加(octaves),6 个生物参数(温度、湿度、大陆度、侵蚀、weirdness、深度),再加整套缓存不然每次全重算。

结果漂亮。但算得贵,存生成好的 chunk 要内存。

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);  // chunk 坐标
  int _z = floor(z / CHUNK_SIZE);
  int rx = x % CHUNK_SIZE;          // chunk 内偏移
  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 在视频里一步步演示:先是 chunk 的 4 个角,每个由 RNG 定 seed。

chunk 的 4 个角,每个由确定性 RNG 定 seed

然后 4 个点之间插值,连成面。

4 个角之间做双线性插值

相邻 chunk 全套同样 pattern,无限连起来。

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

确定性 RNG

让一切成立的关键是 seed。每个 chunk 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 位全局 seed
  return splitmix64(*((uint64_t *)buf));  // 哈希
}

16 位 X 加 16 位 Z 加 32 位 seed,打包进 8 字节 buffer,全丢进 splitmix64。结果:每个位置基于世界 seed 的唯一确定值。

get 到机制的威力了吗?服务器不用存地形。玩家走到新区现算,每次结果一模一样。

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 字节 buffer(X + Z + seed)当 64 位整数吃,吐 32 位哈希。确定,快,5 行。

为什么不是 Perlin 噪声

p2r3 在视频里原话:"随机数用的 digit 越多,地形越规则,像抛硬币越多越接近 50/50"。实际就是 hash 里拼多少个 bit:

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

每个群系选拼几个 bit 抽取。拼越多分布越稳,像抛硬币多了趋近 50/50。拼越少局部起伏越狠。

不规则地形,因子少起伏大

2 个因子的 snowy plains 丘陵起伏,近乎山地。峰谷频繁。

规则地形,因子多表面平

4 个因子的平原又平又稳。分布收敛。

chunk 在 ESP32 上 200ms 生成,同硬件 Perlin 噪声贵到测不出时间。

最骚的:问一个方块不用生成整个 chunk

你玩,挖个方块。服务器要知道给你什么掉落。天真做法整块生成。

双线性插值下,平面上任何点直接拿坐标问。chunk 角从玩家位置得,插值得任意偏移的高度。几个数学运算,不生成 chunk。

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 个条件。草/土/石/深板岩/基岩分层。表层块看群系查 biome_top[],平原草、沙漠沙。一串 if 掉进对应层,替代循环和 switch。

洞穴,最懒的镜像

altitude_grotte = CAVE_BASE_DEPTH - (hauteur_surface - y);

地下把表层高度镜像下来。像深板岩大空洞。零算力,一行。

地表镜像生成的洞穴

地形镜像生成洞穴示意图

矿石,XOR 版

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

坐标 XOR 保证每列一个候选。类型只看高度。钻石藏在洞穴最低点下面,挖洞永远有用。

生物群系 tilemap

每个群系是格子里一座圆形岛,类型由 seed 算出的 pattern 定。网格化,可预测,免费。

生物群系 tilemap,每座岛一个群系

每个群系参数编码进表:

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

每个群系编码进 3 张 5 项表:基准高度、表层块、因子数。getHeightAtFromHash 拿到群系查表调地形。15 字节数据替代 Minecraft 整套群系系统。

群系探测器用 seed 定每个 chunk 是什么群系:

// 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 项 pattern,index 由 chunk 坐标定 seed。格子重复但视觉连贯。4 行代码替代 vanilla 整套生物参数系统。

getHeightAtFromHash:地形组装器

核心生成函数,按群系拼 4 个定 seed 的角:

// worldgen.c(简化)

static uint8_t getHeightAtFromHash (int rx, int rz, short cx, short cz,
                                    uint32_t h, uint8_t biome) {
  // 4 个角从 hash 抽,不同角不同偏移
  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 个角从 hash 不同偏移抽。沙漠强制最低高于海平面,一行约束,不用额外生物算力就避开水。

树和仙人掌:概率放

表层生成用同一个 chunk hash 定种哪里:

// worldgen.c(简化)

static void genFoliage (uint8_t *chunk_data, short cx, short cz,
                        uint32_t hash, uint8_t biome) {
  if (biome == BIOME_DESERT) {
    // 仙人掌:每 chunk 一个候选,hash 定位置
    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 {
    // 树:hash 定种不种、种哪
    int tree_count = (hash & 3);  // 每 chunk 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);
    }
  }
}

绿色群系每 chunk 0-3 棵树,沙漠最多 1 仙人掌。chunk hash 是唯一熵源,一个 & 7 定 chunk 内位置,一个 & 3 定数量。全确定,零存储。

generateChunk:全拼起来

拼完整 8x8x256 chunk 的函数:

// worldgen.c(简化)

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

  // chunk 每列(8x8 = 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 x 8 x 256)完整 chunk。chunk 现生成现发客户端,不缓存、不懒加载、不压缩。

存储:全静态数组

Bareiron 内存架构就是纯嵌入式 C 风:只有全局定长数组,无 malloc,无哈希表,无链表。

方块改动

// globals.h,191-196 行

typedef struct {
  short x;      // 2 字节,横向限 32000 格
  short z;      // 2 字节
  uint8_t y;    // 1 字节,纵向限 256 格
  uint8_t block; // 1 字节,限 256 种方块
} BlockChange;

20000 项,约 25000 次改动,相当于一个半 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;  // 跳过箱子数据
    #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 ++;
}

计数器,index,写。数组只追加:不排序,不整理,不管内存。满了新改动直接扔,地形回生成态。

作者对 256 方块上限的原话:"打蜡轻度锈蚀切制铜楼梯什么的就先不做了。"

生物:每头 8 字节

// globals.h,240-251 行(pragma pack(push, 1) 去 padding)

typedef struct {
  uint8_t type;   // 25=鸡,28=牛,95=猪,106=羊,145=僵尸
  short x;
  uint8_t y;      // 血量为 0 时,Y 变成删除前计时器
  short z;
  uint8_t data;   // bit 0-4:血,bit 5:羊剪没剪,bit 6-7:panic 计时器
} MobData;

8 字节。最多 16 个。对齐 padding 全扔。data 字节是手搓 bitfield:5 位血,1 位剪毛,2 位 panic 计时。生物一死 Y 字段变删除前计时。内存复用到 bit。

玩家:压得死死的

玩家数据也 #pragma pack(push, 1),坐标 short + uint8_t,背包定长 uint16_t + uint8_t 数组,flags 字段把攻击冷却、出生状态、潜行、疾跑、吃、加载、移动冷却、合成锁全编进 bit。

主循环: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;
  }

  // 时间到跑服务器 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() 让 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:  // 按状态:握手 / 状态 / 登录
    case 0x01:  // 状态 ping
    case 0x02:  // 插件消息
    case 0x03:  // 登录/配置确认
    case 0x08:  // 聊天
    case 0x0B:  // 客户端状态(重生)
    case 0x11:  // 点容器(管箱子)
    case 0x19:  // 实体交互
    case 0x1D..0x20:  // 移动包(最大的一块)
    case 0x28:  // 玩家动作(挖/放)
    // ... 40+ 个 case
  }
}

switch 编译成静态跳转表,替代动态跳转表、虚表、map。嵌入式完美。

0x1D-0x20 这块最大,管位置更新、摔落伤害、跨 chunk 边界、刷怪、chunk 生成,还有饥饿。全并一个大 fall-through。

Bareiron 服务器代码,6800 行 C

服务器 tick 和生物 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 tick 换方向
      if (mob->data >> 6 < 1) setMobDir(mob, rand() & 7);
      mob->data = (mob->data & 0x3F) | ((mob->data - 0x40) & 0xC0);
    }

    // 唤醒生物周围 chunk
    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 选老公式因为两步算完,无阈值无曲线,纯线性百分比。

被动生物:8 方向查表,约 40 tick 换向。data 字段高 2 位记当前方向,低 6 位记换向计时。

Bareiron 里的生物,僵尸、猪、羊

生物重生

生物不用随机 tick 刷。服务器 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 和地形同一套,chunk 同 seed。有空位就确定性刷。

合成:不要矩阵,if/else

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

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // flag 0x80 起,被箱子占着合成 buffer
  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:  /* 整块(铁块、金块等) */
  }
}

先查 flag 0x80 起没,合成 buffer 被箱子指针征用就锁合成。

再数占了几格、第一个是什么、全一不一样。就这些,熔炉 4 个检查配出来:

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

复杂形状用第一个物品的 index 加相对位置查。配方共享一套匹配,材料定结果。

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 位指针塞得下),起 flag 让这期间没人能合成。

箱子背包每点一次:

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

从合成 buffer 取回指针,按偏移读写格子。箱子数据每格 3 字节(ID 2 字节加数量 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;
}
}

字面就这些。每个移动包扣饱和度。饱和度到零扣饥饿、重置饱和度。疾跑(flag 0x08)双倍扣。

零计时器,零内存分配,零专用算力。现成的包当计数器。

摔落伤害

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

// 玩家离地存 Y
// 落地减
伤害 = last_y_ground - y_actuel;

一个减法。

挖放方块

点方块,0x28 包(玩家动作)进 switch。handler 查位置是什么方块,移除,给玩家背包塞东西:

// 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 加石/土/草/煤分层。

秒烧熔炉

最搞笑的:熔炉不是实体。圆石放"烧制"格、煤放"燃料"格,结果立刻出,无计时无 chunk tick,就是背包格东西放对就清空。

秒烧熔炉,放对材料结果立刻出

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();
  // 剩下事件 handler 接管
}
#endif

整个服务器跑在 FreeRTOS 任务里,4096 字节栈。就这些。主线程只初始化 WiFi 等连接。连上 spawn bareiron_main 调标准 main()。

全部 ESP32 专用代码包 #ifdef ESP_PLATFORM。PC 上全编成标准 POSIX。

砍掉的东西

塞进去的代价,vanilla 那些功能没有:

  • 零网络压缩,zlib 太贵。服务器生成 chunk 快,发出去是瓶颈。
  • 零随机 tick,树拿骨粉催不然不长。生物 chunk 边界刷。
  • 零掉落实体,挖的块直接进背包。动画纯客户端。
  • 背包零校验,信客户端。64 钻石?行。1 秒挖空 chunk?行。熟人局用。
  • 零服务端光照,火把最后发,客户端自己算。
  • 零渐进流体,直接终态。

最终结果

Ryzen 5 3600:每 chunk 约 0.5ms。 1 美元 ESP32-C3:每 chunk 约 200ms。能玩。

chunk 生成 benchmark,Ryzen 对 ESP32

3+ 玩家:卡。作者原话堪比高峰期 2b2t。

多玩家连同一台 Bareiron 服务器

哲学

p2r3:"我就喜欢这个 1 美元、0.5 瓦的小芯片能跑 Minecraft 这种东西的想法。Science isn't about 'why', it's about 'why not'."

每行都是 trade-off:

  • Perlin 噪声 → 插值:丑点,快 200 倍,零内存
  • 合成矩阵 → 手写匹配:代码丑,零字节
  • zlib → 无:烂网死,但能玩
  • 校验 → 信:零安全,零算力

每个砍掉的功能都给另一个功能在硬件限制里让了位。

三点记住:

  1. 插值 + RNG,4 个定 seed 点,无限地形,零存储,不用重生成 chunk 就能查,生成 200ms。这是让其余一切成立的神之一手。
  2. 每个功能都有价,没压缩、没随机 tick、没校验是主动选择:这样才塞得进 520KB。
  3. 最脏的 hack 最聪明,箱子 memcpy 进方块数组、移动包扣饥饿、秒烧熔炉。正经解法都太贵。

项目感兴趣,GitHub 全有 GPLv3:https://github.com/p2r3/bareiron/。C 写得又脏又爽,我好久没读源码读这么开心了。

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

6800行のC、mallocなし、Perlinノイズをbilinear補間に置き換え、タイルマップ式バイオーム。全部1ドルのチップの上で。

はじめに

1ドルのマイコンでMinecraftサーバーを動かすなんて、冗談みたいに聞こえる。

私は自問した。答えはイエスだ。文字通りね。

Bareironっていうプロジェクトがあって、p2r3作で、ここ数年Minecraft界隈で見た中でたぶん一番魅惑的なプロジェクトの1つだ。300キロバイトに収まるバイナリ、6800行のC、外部依存ゼロ、mallocなし、スレッドなし。それが1ドルのESP32で動く。

ESP32-C3、サーバーを動かすマイコン

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

0.5ワット消費でクロック160MHzのチップの上でだ。

規模感を言うと、バニラのMinecraftサーバーは数ギガのRAMが要る。ESP32-C3はSRAM 520KB(起動後に残るのは400)。20年前のプロセッサはすでにギガヘルツで回ってたのに、こいつは160MHzが上限だ。純粋な性能差はざっと2万倍だ。

p2r3はMinecraftサーバーをCで書いたんじゃない。この制約に収めるためにサーバーの各部品を reinvent したんだ。ソースコードを開けてどうなってるか見ていこう。

p2r3によるBareiron紹介動画のサムネイル

プロジェクトの心臓部:メモリ不要の地形生成

組み込みMCサーバーを作りたいとき、一番の問題は地形生成だ。

バニラMinecraftでは世界はPerlinノイズで生成される。重ねた複数層(オクターブ)、6つのバイオームパラメータ(気温、湿度、大陸性、浸食、weirdness、深度)、毎回全部再計算しないためのキャッシュ機構いっぱいだ。

結果は壮麗だ。でも計算は重いし、生成済みチャンク保存にRAMを食う。

Bareironのアプローチは根本的に違う。ノイズを重ねる代わりに、決定的RNGが作る4点に対するbilinear補間を使う。

小さなピクセル画像を拡大したとき縁がぼやけるだろ。まさにあれだ。

// 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隅間の補間
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

標準の双線形補間だ。4つの隅、位置による重み、出力はuint8_t1つ。CHUNK_SIZEは8だから整数乗算だけでできて、floatなしだ。

p2r3は動画で段階的に見せてる。まずチャンクの4隅で、それぞれRNGを種にして高さを作る。

チャンクの4隅、それぞれ決定的RNGを種に

次にこの4点間に補間をかけて連続面を作る。

4隅間のbilinear補間の適用

隣り合う全チャンクでパターンを繰り返せば、無限に広がる地形になる。

最終結果:連続した起伏地形

決定的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ノイズじゃないのか

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因子だと平原は平らで予測可能に保たれる。分布が安定する。

チャンク生成はESP32で200msだ。同じハードでPerlinノイズだと重すぎて計測不能なくらいだ。

唸る詳細:チャンク全体を生成せずにブロックを問い合わせる

プレイしててブロックを掘る。サーバーはどのアイテムを渡すか知らなきゃいけない。素朴にはチャンク全体を生成することになる。

bilinear補間なら、座標から平面上の任意の点を直接問い合わせられる。チャンクの隅はプレイヤー位置から出る。補間が任意オフセットの高さをくれる。一握りの算術演算で、チャンク生成なしだ。

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だ。単なるifカスケードで正しい層に落ちる。ループもswitchも置き換えてる。

洞窟、一番怠けたミラー

altitude_grotte = CAVE_BASE_DEPTH - (hauteur_surface - y);

地下で表面高さをミラーする。deepslateの大洞窟っぽく見える。計算ゼロ、1行だ。

表面地形のミラーで作った洞窟

洞窟生成のための地形ミラーの図

鉱石、XOR版

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

座標のXORで列ごとに候補を1つ保証する。種類は高度だけで決まる。ダイヤは掘る意味が残るよう洞窟最低点の下に隠してある。

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

各バイオームはグリッド上の円形島で、種類はシードから計算したパターンで決まる。グリッド化、予測可能、タダだ。

タイルマップ式バイオーム地図、各島が違うバイオーム

各バイオームは独自パラメータをテーブルにエンコードして持つ。

// 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因子だが基準が高いから木の生えた丘になる。

タイガ:高さ46、3因子から中程度の変動、寒い地形だ。

雪原:高さ40、2因子だけだから一番起伏が激しい。

h = (hash % 5) + ((hash >> 4) % 5);
// 結果:最大プラスマイナス14ブロックの変動

各バイオームは5エントリのテーブル3つにエンコードされてる。基準高さ、表面ブロック、因子数だ。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行のコードでバニラのバイオームパラメータ機構全部を置き換えてる。

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隅はハッシュからずらし違いで抽出する。砂漠は海面以上を強制する。追加のバイオーム計算なしで水を避ける1行制約だ。

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

表面生成は同じチャンクハッシュでどこに植えるか決める。

// worldgen.c(簡略化)

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(簡略化)

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;

2万エントリで、約25,000変更だ。チャンク1個半を丸ごと掘り返す相当だ。blockフィールドが0xFFなら空きエントリだ。検索は線形スキャンだ。

ブロック配列のメモリレイアウト、1エントリ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;
}

追加も検索と同じくらい直接的だ。

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

カウンタとインデックスと書き込み1回だ。テーブルは積むだけだ。ソートも圧縮もメモリ管理もなし。テーブルがいっぱいになったら新しい変更は無視される。地形は生成状態に戻る。

256ブロック制限についての作者コメントは「少し緑青を帯びた磨かれた銅の階段を実装する予定は当分ない」だ。

モブ:パイプ1本あたり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フィールドは攻撃クールダウン、スポーン状態、スニーク、スプリント、食事、load、移動クールダウン、クラフトロックをエンコードする。全部個別ビットだ。

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

サーバー全体はループ1つ、スレッド1つ、イベントライブラリゼロで回る。

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

  // ラウンドロビン: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()でFreeRTOSのidleタスクを呼吸させる。これがないとウォッチドッグタイマーがチップをリセットする。

パケット振り分けは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(リスポーン)
    case 0x11:  // Click container(チェストを扱う)
    case 0x19:  // Interact entity
    case 0x1D..0x20:  // Movementパケット(一番大きいケース)
    case 0x28:  // Player action(掘るや置く)
    // ... 40超のケース
  }
}

switchは静的ジャンプテーブルにコンパイルされる。動的ジャンプテーブルやvtableやmapの代わりだ。組み込みには最適だ。

0x1D-0x20ケースが一番大きくて、位置更新、落下ダメージ、チャンク境界横断、モブスポーン、チャンク生成、それに空腹まで扱う。全部1つの大きなfall-throughだ。

Bareironサーバーのコード、6800行のC

サーバーtickとモブAI

handleServerTick関数は50msごと(20TPS)に呼ばれる。メインループがプレイヤーを扱う間、世界を動かす。

// 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];
      // 約40tickごとに方向転換
      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ハート毎秒だ。パスファインディングなしだとゾンビは簡単にkiteできるから、p2r3はわざと高めにした。

防具式は戦闘アップデート前のもので、一番単純なやつだ。

// main.c(簡略化)

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方向のルックアップテーブルで、約40tickごとに進路変更する。dataフィールドは進行方向を上位2ビットに、方向転換タイマーを残り6ビットにエンコードする。

Bareironのモブ、ゾンビとブタとヒツジ

モブのリスポーン

モブはランダムtickで湧かない。サーバーtickが新しいチャンク境界に出会ったときに現れる。

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;

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

クラフトバッファからポインタを取り戻して、オフセットでスロットにアクセスする。チェストデータは1スロット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行だ。各移動パケットが満腹度を減らす。満腹度がゼロになったら空腹が減って満腹度リセットだ。スプリント(フラグ0x08)は消耗2倍だ。

タイマーゼロ、確保メモリゼロ、専用計算ゼロ。既存パケットを減らすカウンタだ。

落下ダメージ

プロジェクト最単純のダメージ機構だ。

// プレイヤーが地面を離れたらYを保存
// 地面に戻ったら引き算
degats = dernier_y_au_sol - y_actuel;

引き算1つだ。

掘ると置く

ブロックをクリックすると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の層だ。

即時かまど

一番笑えるのは、かまどがエンティティとして存在しないことだ。「焼く」枠に丸石を、「燃料」に石炭を置いたら、結果は即出る。タイマーもチャンクtickもなしだ。正しいアイテムを置いたら空くインベントリ枠でしかない。

即時かまど、材料を置けば結果は即時

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をspawnする。

ESP32固有コードは全部#ifdef ESP_PLATFORMで守られてる。PCでは全部標準POSIXコードとしてコンパイルされる。

犠牲になったもの

全部収めるために、バニラの機能で存在しないものがある。

  • ネットワーク圧縮ゼロ。zlibは高すぎる。サーバーはチャンクを速く作るけど、送る方がボトルネックだ。
  • ランダムtickゼロ。木は骨粉かそのまま育つ。モブはチャンク境界で湧く。
  • アイテムエンティティゼロ。掘ったブロックは直接インベントリ行きだ。アニメは純粋に見た目だけだ。
  • インベントリ検証なし。クライアントを信頼する。ダイヤ64個?OKだ。1秒でチャンク1個掘る?OKだ。信頼できる仲間で使う用だ。
  • サーバー側光源ゼロ。松明は全部の後に送られて、クライアントが計算する。
  • 漸進流体ゼロ。即最終状態だ。

最終結果

Ryzen 5 3600:チャンクあたり約0.5ms。 1ドルのESP32-C3:チャンクあたり約200ms。遊べる。

チャンク生成ベンチマーク、Ryzen対ESP32

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

同じBareironサーバーに接続した複数プレイヤー

哲学

p2r3いわく「0.5ワット消費の1ドル小チップがMinecraftみたいな高度なものを動かせるって発想が好きなんだ。科学は『なぜ』じゃなく『なぜやらないのか』だよ」。

各行がトレードオフだ。

  • Perlinノイズから補間へ:見た目は劣る、200倍速い、メモリゼロ
  • クラフト行列からハードコード照合へ:汚いコード、ゼロバイト
  • zlibから無しへ:回線悪ければ死ぬ、でも遊べる
  • 検証から信頼へ:安全ゼロ、計算ゼロ

欠けた各機能が、ハードの制限内で別の機能の存在を許してる。

覚えるべき3点:

  1. 補間とRNG。種付き4点、無限地形、保存ゼロ、チャンク再生成なしの問い合わせ、生成200ms。これが他全部を可能にした天才の一手だ。
  2. 各機能にコストがある。圧縮もランダムtickも検証もないのは自覚的な選択だ。520KBに収めるのを許してる。
  3. 汚いハックが一番賢い。memcpy経由でブロックテーブルに入れるチェスト、移動パケットで回す空腹、即時かまど。きれいな解法は高すぎたんだ。

興味が出たら、全部GPLv3でGitHubにある。かなり汚いCだけど、ソース読んでこんなに楽しんだのは久しぶりだ。

Bareiron. 1달러짜리 마이크로컨트롤러에서 도는 Minecraft 서버

C 6800줄, malloc 제로, Perlin 노이즈를 이중선형 보간으로 대체, 타일 맵 바이오옴, 전부 1달러 칩 위에서.

소개

1달러짜리 마이크로컨트롤러에서 Minecraft 서버를 돌리기. 농담 같아 보여요.

저는 스스로 물었고, 답은 네예요. 말 그대로요.

p2r3 서명 Bareiron이라는 프로젝트가 있어요. 최근 몇 년 Minecraft 세상에서 본 가장 매혹적인 프로젝트 중 하나일 거예요. 300킬로바이트에 들어가는 바이너리, C 6800줄, 외부 의존성 제로, malloc 없음, 스레딩 없음, 1달러 ESP32에서 돌아가요.

서버를 돌리는 마이크로컨트롤러 ESP32-C3

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

0.5와트를 쓰고 160MHz 클럭인 칩 위에서요.

감 잡으려고요. 바닐라 Minecraft 서버는 수 기가 RAM이 필요해요. ESP32-C3는 SRAM 520KB(부팅 뒤 400 사용 가능)예요. 20년 전 프로세서도 기가헤르츠로 돌았어요. 이것은 160MHz가 한계예요. 순수 힘의 차이가 약 20,000배예요.

p2r3는 Minecraft 서버를 C로 쓴 것이 아니라, 서버 벽돌마다 이 제약에 들어가도록 다시 발명했어요. 소스 코드를 열면서 어떻게 했는지 볼게요.

p2r3의 Bareiron 소개 영상 썸네일

프로젝트 두뇌: 기억 없는 지형 만들기

임베디드 MC 서버를 만들고 싶을 때 가장 큰 문제가 지형 만들기예요.

바닐라 Minecraft에서 월드는 Perlin 노이즈로 만들어져요. 겹친 여러 층(옥타브), 바이오옴 매개변수 6개(온도와 습도, 대륙성, 침식, weirdness, 깊이), 매번 전부 다시 계산하지 않으려고 캐싱 시스템 전체.

결과는 아름다워요. 하지만 계산이 비싸고, 만든 청크를 저장하려고 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);  // 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);
}

표준 이중선형 보간: 점 4개, 위치 따른 가중치, 출력 uint8_t 하나. CHUNK_SIZE가 8이라 정수 곱셈으로 돼요. float 없어요.

p2r3가 영상에서 단계별로 보여줘요. 먼저 청크 점 4개, 각자 RNG 시드 높이를 가져요.

RNG 결정 시드가 있는 청크 점 4개

다음에 이 4개 점 사이 보간이 연속 표면을 만들어요.

4개 점 사이 이중선형 보간 적용

그리고 인접 모든 청크에 패턴을 반복하면 무한히 뻗는 지형이 나와요.

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

결정적 RNG

이 모든 것을 가능하게 하는 열쇠가 시딩이에요. 각 청크에 점 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
}

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와 시프트, 곱셈, xor와 시프트, 곱셈, xor와 시프트. 전부 직접 연산에 들어가요. 표도 루프도 없어요. 8바이트 버퍼(X + Z + seed)를 64비트 정수로 보고 32비트 해시를 돌려줘요. 결정적이고 빠르고 5줄이에요.

왜 Perlin 노이즈가 아닌지

p2r3가 영상에서 직접 말해요. "난수 자릿수를 많이 더할수록 지형이 규칙적으로 돼요. 동전 던지기를 많이 할수록 50대 50에 가까워지는 것처럼요." 실제로는 해시 비트를 몇 개 합치는지예요.

// worldgen.c, lignes 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개로 평원은 평평하고 예측 가능해져요. 분포가 안정돼요.

청크 하나가 ESP32에서 200ms 만에 만들어져요. 같은 하드웨어에서 Perlin 노이즈로는 너무 비싸서 측정할 수 없는 시간과 비교해요.

죽이는 세부 사항: 청크 전체를 만들지 않고 블록 묻기

플레이하고 블록을 캐요. 서버가 어떤 아이템을 줄지 알아야 해요. 순진하게는 청크 전체를 만들어야 해요.

이중선형 보간으로는 평면의 어떤 점이든 좌표에서 바로 물어봐요. 플레이어 위치에서 청크 점을 얻고, 보간이 어떤 오프셋이든 높이를 줘요. 수학 연산 몇 개, 청크 만들기 없어요.

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. 단순 if 연속이 맞는 층에 떨어져요. 루프와 switch를 대신해요.

동굴, 가장 게으른 거울

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이 열마다 후보 하나를 보장해요. 종류는 고도에만 달려 있어요. 다이아몬드는 파는 맛을 살리려고 동굴 최저점 아래 숨겨요.

타일 맵 바이오옴

각 바이오옴은 격자의 원형 섬이고, 종류는 시드에서 계산된 패턴으로 정해져요. 격자, 예측 가능, 무료.

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

각 바이오옴은 표에 인코딩된 자기 매개변수 묶음이 있어요.

// 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);
// 결과: 최대 앞뒤 4블록 변동

Desert: 높이 52, 요소 3개, 표면 블록 = 모래. 절대 해수면 아래 없어요.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// 결과: 최대 앞뒤 6블록 변동, SEA_LEVEL+1로 고정

Forest: 높이 50, plains처럼 요소 4개지만 토대가 높아요 → 숲 언덕.

Taiga: 높이 46, 요소 3개 → 중간 변동, 추운 지형.

Snowy plains: 높이 40, 요소 2개뿐 → 가장 험준.

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

각 바이오옴은 5개 항목 표 3개로 인코딩돼요. 토대 높이와 표면 블록, 요소 수. getHeightAtFromHash가 바이오옴을 받으면 이 표들을 봐서 지형을 조정해요. 바닐라 Minecraft 바이오옴 시스템 전체를 대신하는 자료 15바이트.

청크에 어떤 바이오옴이 해당하는지 시드로 정하는 바이오옴 감지기예요.

// 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개 항목 패턴 하나, 청크 좌표 시드 인덱스 하나. 반복 격자지만 시각적으로 일관돼요. 바닐라 Minecraft 바이오옴 매개변수 시스템 전체를 대신하는 코드 4줄.

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개를 해시에서 다르게 이동시켜 뽑아요. 사막은 최소값을 해수면 위로 강제해요. 추가 바이오옴 계산 없이 물을 피하는 제약 한 줄.

나무와 선인장: 확률 배치

표면 만들기가 같은 청크 해시로 어디에 심을지 정해요.

// worldgen.c (simplifié)

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 (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바이트: 가로 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;
}

변경 추가도 찾기만큼 직접적이에요.

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블록 한도 저자 코멘트: "patiné légèrement ciré 구리 계단은 당분간 구현 안 할 거예요."

몹: 파이프 머리당 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: 체력, 비트 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, 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();
  }

  // 라운드 로빈: 반복마다 클라이언트 하나, 패킷 하나
  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 태스크가 숨 쉬게 해요. 없으면 와치독 타이머가 칩을 리셋해요.

패킷 보내기는 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:  // 상태 핑
    case 0x02:  // 플러그인 메시지
    case 0x03:  // 로그인과 설정 확인
    case 0x08:  // 채팅
    case 0x0B:  // 클라이언트 상태 (리스폰)
    case 0x11:  // 컨테이너 클릭 (상자 다뤄요)
    case 0x19:  // 개체 상호작용
    case 0x1D..0x20:  // 움직임 패킷 (가장 큰 경우)
    case 0x28:  // 플레이어 행동 (파기와 놓기)
    // ... 40개 이상 경우
  }
}

switch가 정적 점프 표로 컴파일돼서, 동적 점프 표와 vtable, 맵을 대신해요. 임베디드에 완벽해요.

0x1D-0x20 경우가 가장 커요. 위치 고치기와 낙하 피해, 청크 경계 넘기, 몹 스폰, 청크 만들기, 배고픔을 다뤄요. 하나의 큰 fall-through에 전부요.

Bareiron 서버 코드: C 6800줄

서버 틱과 몹 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비트에 인코딩해요.

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

복잡한 모양은 첫 아이템 인덱스를 쓰고 상대 위치를 확인해요. 레시피가 같은 맞추기 함수를 공유해요. 재료가 결과를 정해요.

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

코드 주석에도요. // 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이 이제 상자 자료를 가리켜요
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, 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줄. 움직임 패킷마다 포만감이 내려가요. 포만감이 0이 되면 배고픔이 내려가고 포만감을 리셋해요. 달리기(깃발 0x08)가 소모를 2배로 해요.

타이머 제로, 할당 메모리 제로, 전용 계산 제로. 이미 존재하는 패킷에 내려가는 카운터 하나.

낙하 피해

프로젝트에서 가장 단순한 피해 시스템이에요.

// 땅을 떠나면 Y를 저장해요
// 땅에 다시 닿으면 빼요
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: {
      // 누른 위치 블록 종류 정하기
      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, 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();
  // 나머지는 이벤트 핸들러가 다뤄요
}
#endif

서버 전체가 4096바이트 스택 FreeRTOS 태스크에서 돌아요. 그게 전부예요. 주 스레드는 WiFi를 초기화하고 연결을 기다릴 뿐이에요. 연결되면 표준 main()을 부르는 bareiron_main을 만들어요.

ESP32 전용 코드는 모두 #ifdef ESP_PLATFORM으로 지켜져요. PC에서는 전부 표준 POSIX 코드로 컴파일돼요.

희생된 것

이 모든 것이 들어가려고 바닐라 기능 중 없는 것들이 있어요.

  • 네트워크 압축 제로: zlib가 너무 비싸요. 서버가 청크를 빨리 만들지만 보내는 것이 병목이에요.
  • 무작위 틱 제로: 나무가 뼛가루로 자라거나 아니에요. 몹이 청크 경계에 스폰해요.
  • 아이템 개체 제로: 캔 블록이 바로 인벤토리에 가요. 애니메이션은 순수 시각.
  • 인벤토리 검사 없음: 클라이언트를 믿어요. 다이아몬드 64개? 좋아요. 1초에 캔 청크? 좋아요. 믿을 만한 사람들 사이에서 쓰세요.
  • 서버 조명 제로: 횃불이 맨 마지막에 보내지고 클라이언트가 계산해요.
  • 점진 유체 제로: 바로 최종 상태.

최종 결과

Ryzen 5 3600: 청크당 ~0.5ms. 1달러 ESP32-C3: 청크당 ~200ms. 할 만해요.

청크 만들기 벤치: Ryzen 대 ESP32

3명 이상 플레이어: 버벅여요. 저자 말로 피크 시간 2b2t와 비슷해요.

같은 Bareiron 서버에 접속한 여러 플레이어

철학

p2r3: "0.5와트 먹는 1달러짜리 작은 칩이 Minecraft만큼 진보한 것을 돌릴 수 있다는 생각이 좋아요. 과학은 '왜'가 아니라 '왜 안 돼'가 아니에요."

매 줄이 맞바꾸기예요.

  • Perlin 노이즈에서 보간: 덜 예쁘고 200배 빠르고 메모리 제로
  • 제작 행렬에서 하드코딩 맞추기: 지저분한 코드, 바이트 제로
  • zlib에서 없음: 연결이 구리면 죽음이지만 할 만해요
  • 검증에서 믿음: 보안 제로, 계산 제로

빠진 기능마다 하드웨어 한도 안에서 다른 기능이 존재하게 해요.

기억할 3가지:

  1. 보간 + RNG: 시드 점 4개, 무한 지형, 저장 제로, 청크 다시 만들지 않고 묻기, 200ms 만들기. 나머지 모두를 가능하게 하는 천재 수예요.
  2. 기능마다 비용이 있어요: 압축과 무작위 틱, 검증 부재는 가정한 선택이에요. 520KB에 들어가는 것이 바로 그 덕분이에요.
  3. 지저분한 핵이 가장 영리해요: memcpy로 블록 표 속 상자, 움직임 패킷으로 배고픔, 즉석 화로. 깔끔한 해법은 너무 비쌌을 거예요.

프로젝트에 관심 있으면 전부 GPLv3 GitHub에 있어요. 제대로 지저분한 C고, 소스 코드를 읽으면서 이렇게 즐거웠던 적이 드물어요.

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 bilinear interpolation, biomi in tile map, e tutto su un chip da 1$.

Introduzione

Far girare un server Minecraft su un microcontrollore da 1 dollaro, sembra uno scherzo.

Mi sono fatto la domanda, 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 in questi 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, le microcontrôleur qui fait tourner le serveur

Generazione di terreno infinita. Biomi. Grotte. Craft. Miniere. Mob. Fame. Casse. Tutto ciò che ti aspetti da un server survival.

Su un chip che consuma 0.5 Watt e che ha 160 MHz di clock.

Per dare un ordine di idea: un server Minecraft vanilla ha bisogno di vari giga di RAM. L'ESP32-C3, sono 520 KB di SRAM (400 disponibili dopo il boot). I processori 20 anni fa giravano già in gigahertz: questo si ferma 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 mattone del server perché stesse in questi vincoli. Guardiamo come, aprendo il codice sorgente.

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

Il cervello del progetto: una generazione di terreno senza memoria

Il problema più grosso quando vuoi fare un server MC embedded, è la generazione del terreno.

In Minecraft vanilla, il mondo è generato con Perlin noise: più 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 costa in calcolo, e prende RAM per salvare i chunk generati.

L'approccio di Bareiron è radicalmente diverso. Invece di impilare rumore, usa bilinear interpolation su 4 punti generati da un RNG deterministico.

Sai quando ingrandisci una piccola immagine pixelata e i bordi diventano sfocati? È esattamente quello.

// worldgen.c, righe 117-171 (semplificato)

// interpola i 4 angoli in base alla posizione nel chunk
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);
}

// altezza del terreno in un punto dalle coordinate mondo
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 seminati da hash + bioma
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

L'interpolazione bilineare standard: 4 angoli, pesi secondo la posizione, un solo uint8_t in uscita. CHUNK_SIZE è 8, quindi si fa in moltiplicazioni intere, niente float.

p2r3 lo mostra passo passo nel video: prima i 4 angoli del chunk, ognuno con un'altezza seminata dal RNG.

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

Poi l'interpolazione tra questi 4 punti crea una superficie continua.

Application de la bilinear interpolation entre les 4 coins

E ripetendo il pattern su tutti i chunk adiacenti, si ottiene un terreno che si estende all'infinito.

Résultat final : terrain irrégulier continu

L'RNG deterministico

La chiave che rende tutto questo 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

// hash unico per chunk da coordinate + seed mondo
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 sulla seed del mondo.

Cogli la potenza del meccanismo? Il server non ha bisogno di salvare 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-rapido pensato per gli hash a 64 bit:

// worldgen.c (semplificato)

// prng veloce per hash a 64 bit
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. Il tutto sta in operazioni dirette, senza tabella né ciclo. Prende il buffer di 8 byte (X + Z + seed), lo tratta come un intero a 64 bit, e restituisce 32 bit di hash. È deterministico, rapido, e sta in 5 righe.

Perché non è Perlin noise

p2r3 lo dice lui stesso nel video: "più aggiungi cifre del numero casuale, più il terreno diventa regolare, come più lanci di moneta ti avvicinano a 50/50". In pratica, è il numero di bit dell'hash che combina:

// worldgen.c, righe 51-115

// Per un bioma pianure: 4 fattori combinati → terreno regolare
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Per pianure innevate: 2 fattori → più accidentato
h = (hash % 5) + ((hash >> 4) % 5);

Ogni bioma sceglie quante estrazioni di bit combina. Più ce ne sono, più la distribuzione si stabilizza: come più lanci di moneta che si avvicinano a 50/50. Meno ce ne sono, più le variazioni locali sono forti.

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

Con solo 2 fattori, le pianure innevate producono un terreno collinare, quasi montuoso. Picchi e avvallamenti sono frequenti.

Terrain régulier -- facteurs multiples, surface lisse

Con 4 fattori, le pianure restano 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 costa.

Il dettaglio che spacca: interrogare un blocco senza generare tutto il chunk

Giochi, mini un blocco. Il server deve sapere che item darti. Ingenuamente, servirebbe generare tutto il chunk per questo.

Con la bilinear interpolation, 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 può dirmi che blocco si trova a una coordinata data, senza accedere alla memoria né calcolare costose mappe di rumore". Esattamente ciò che ha fatto.

Ecco come l'altezza diventa blocchi concreti:

// worldgen.c (semplificato)

// restituisce il tipo di blocco a una coordinata
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 via biome_top[]: grass per le pianure, sand per il deserto. Una semplice cascata di if cade nello strato giusto, e questo sostituisce ciclo e switch.

Le grotte, mirror più pigro

// altezza grotta = mirror della superficie in profondità
altitude_grotte = CAVE_BASE_DEPTH - (hauteur_surface - y);

Fa il mirror dell'altezza della superficie sottoterra. Assomiglia alle grandi cavità deepslate. Zero compute, una riga.

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

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

I minerali, versione XOR

// un candidato per colonna via XOR di coordinate
candidat = (chunk_x ^ col_x ^ col_z) % 100;
if (candidat < 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 perché scavare resti utile.

I biomi in tile map

Ogni bioma è un'isola circolare in una griglia, il suo tipo determinato da un pattern calcolato dalla seed. Grigliato, prevedibile, e gratis.

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

Ogni bioma ha il suo set di parametri codificato in tabelle:

// worldgen.c (semplificato)

// altezze di base per bioma
static const uint8_t biome_base[] = {
  [BIOME_PLAINS]  = 48,   // altezza di base: 48
  [BIOME_DESERT]  = 52,   // leggermente più alto
  [BIOME_FOREST]  = 50,   // in mezzo
  [BIOME_TAIGA]   = 46,   // un po' più basso
  [BIOME_SNOWY]   = 40,   // il più basso
};

// blocco di superficie per bioma
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,
};

// numero di fattori di rumore per bioma
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, poi terreno molto piatto, erba.

// pianure: 4 fattori → variazione di ±4 blocchi max
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Risultato: variazione di ±4 blocchi max

Desert: altezza 52, 3 fattori, blocco superficie = sabbia. Mai sotto il livello del mare.

// deserto: 3 fattori → variazione di ±6 blocchi max
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// Risultato: variazione di ±6 blocchi max, limitato a SEA_LEVEL+1

Forest: altezza 50, 4 fattori come le pianure ma base più alta, poi colline boscose.

Taiga: altezza 46, 3 fattori, poi variazioni moderate, terreno freddo.

Snowy plains: altezza 40, solo 2 fattori, poi il più accidentato.

// pianure innevate: 2 fattori → variazione di ±14 blocchi max
h = (hash % 5) + ((hash >> 4) % 5);
// Risultato: variazione di ±14 blocchi max

Ogni bioma è codificato in 3 tabelle da 5 voci: altezza di base, blocco di superficie, numero di fattori. Quando getHeightAtFromHash riceve il bioma, consulta queste tabelle per regolare il terreno. 15 byte di dati per sostituire tutto il sistema di biomi di Minecraft.

Il rilevatore di bioma usa la seed per determinare che bioma corrisponde a ogni chunk:

// worldgen.c (semplificato)

// pattern di 16 voci per assegnare i biomi
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,
};

// bioma di un chunk da coordinate + seed
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 seminato 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 cuore della generazione combina i 4 angoli seminati per bioma:

// worldgen.c (semplificato)

// combina i 4 angoli seminati e interpola
static uint8_t getHeightAtFromHash (int rx, int rz, short cx, short cz,
                                    uint32_t h, uint8_t biome) {
  // 4 angoli estratti dall'hash, seed diversa 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 scostamenti diversi. Il deserto forza il minimo sopra il livello del mare: una riga di vincolo che evita l'acqua senza calcolo biomico extra.

Alberi e cactus: piazzamento probabilistico

La generazione di superficie usa lo stesso hash di chunk per decidere dove piantare:

// worldgen.c (semplificato)

// piazza alberi o cactus in base a hash e bioma
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 decide 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 decide se metterne e dove
    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 è salvato.

generateChunk: assemblare tutto

La funzione che mette tutto insieme per produrre un chunk completo di 8×8×256 blocchi:

// worldgen.c (semplificato)

// genera un chunk completo colonna per colonna
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);

      // Riempi la colonna dal basso in alto
      for (int y = 0; y < height; y ++) {
        uint8_t block = getTerrainBlock(wx, y, wz);
        chunk[y * 64 + z * 8 + x] = block;
      }
    }
  }

  // Aggiungi gli elementi di superficie (alberi, cactus)
  genFoliage(chunk, cx, cz, hash, biome);
}

È tutto. 3 cicli annidati: per ogni colonna, trova l'altezza, riempi i blocchi, passa alla successiva. L'uscita è un uint8_t[16384] (8 × 8 × 256) che rappresenta il chunk completo. Il chunk è generato poi inviato diretto al client, senza passare da caching, lazy loading o compressione.

Lo storage: array statici ovunque

L'architettura memoria di Bareiron, è C embedded in tutto il suo splendore: solo tabelle globali di taglia fissa, né malloc, né hash map, né liste concatenate.

I cambiamenti di blocchi

// globals.h, righe 191-196

// un cambio di blocco fatto dal giocatore
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 blocchi
} BlockChange;

20 000 voci, cioè circa 25 000 cambiamenti: l'equivalente di un chunk e mezzo interamente scavato. Il campo block a 0xFF segna una voce libera. La ricerca è una scansione lineare:

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

// procedures.c

// cerca un cambio giocatore a una posizione
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 cassa
    #endif
  }
  return 0xFF;
}

Aggiungere un cambiamento è diretto come la ricerca:

// accoda un cambio di blocco
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. La tabella si limita ad accodare: nessun ordinamento, nessuna compattazione, nessuna gestione memoria. Quando la tabella è piena, i nuovi cambiamenti sono ignorati: il terreno torna al suo stato generato.

Il commento dell'autore sul limite a 256 blocchi: "non conto di implementare le scale in rame leggermente patinato cerato tanto presto."

I mob: 8 byte per testa di pipe

// globals.h, righe 240-251 (pragma pack(push, 1) per eliminare il padding)

// stato di un mob in 8 byte
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 max. Allineamento e padding vanno a farsi friggere. Il byte data è un bitfield casalingo: 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. Riuso di memoria a livello di bit.

I giocatori: impacchettati stretti

I dati giocatori usano #pragma pack(push, 1) anche loro: coordinate in short + uint8_t, inventari in tabelle fisse di uint16_t + uint8_t, e un campo flags che codifica sia il cooldown di attacco, lo stato di spawn, sneak, sprint, eat, load, movement cooldown, e il lock di craft. Tutto in singoli bit.

Il ciclo principale: while(true) e non-bloccante

Il server intero gira su un ciclo, un thread, zero event library.

// main.c, righe 594-720

// ciclo principale: accetta client, ticka, elabora un pacchetto
while (true) {
  task_yield();  // lascia respirare il watchdog su ESP32

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

  // Leggi 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 è trattato per iterazione del ciclo, e un solo pacchetto è letto alla volta. Il task_yield() all'inizio del ciclo lascia il task idle di FreeRTOS respirare su ESP32: senza, il timer watchdog ti resetta il chip.

Il dispatch dei pacchetti, è uno switch mostruoso di 400 righe:

// main.c, righe 68-497

// smista un pacchetto in base al suo ID
void handlePacket (int client_fd, int length, int packet_id, int state) {
  switch (packet_id) {
    case 0x00:  // Handshake / Status / Login secondo lo 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 le casse)
    case 0x19:  // Interact entity
    case 0x1D..0x20:  // Pacchetti movimento (il caso più grosso)
    case 0x28:  // Player action (dig/place)
    // ... oltre 40 casi
  }
}

Uno switch compila in jump table statica, e questo sostituisce jump table dinamica, vtable e map. Perfetto per l'embedded.

Il caso 0x1D-0x20 è il più grosso: gestisce gli aggiornamenti di posizione, i danni da caduta, gli attraversamenti di frontiere di chunk, lo spawn di mob, la generazione di chunk, E la fame. Tutto in un solo grosso fall-through.

Le code du serveur Bareiron -- 6800 lignes de C

Il tick server e l'IA dei mob

La funzione handleServerTick è chiamata ogni 50 ms (20 TPS). Gestisce il mondo mentre il ciclo principale si occupa dei giocatori:

// main.c (semplificato)

// aggiorna i mob a ogni tick server
void handleServerTick (uint32_t delta) {
  // Aggiorna 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 di direzione ogni ~40 tick
      if (mob->data >> 6 < 1) setMobDir(mob, rand() & 7);
      mob->data = (mob->data & 0x3F) | ((mob->data - 0x40) & 0xC0);
    }

    // Risveglia 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--. Lo zombie ignora pathfinding, A* e obstacle avoidance: regola X e Z in modo indipendente verso il giocatore: attraversa i muri se ce ne sono.

I danni da contatto sono a 3 cuori/sec. p2r3 l'ha voluto alto perché l'assenza di pathfinding rende gli zombie facili da kitare.

La formula di armatura è quella di prima del combat update: la più semplice possibile:

// main.c (semplificato)

// riduzione danno lineare pre-1.9
static uint8_t applyArmor (uint8_t damage, uint16_t armor_value) {
  // Formula pre-1.9: riduzione lineare
  // Ogni punto di 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 da 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 di peso forte, e il timer di cambio direzione nei 6 bit restanti.

Mobs dans Bareiron -- zombies, cochons, moutons

Il respawn dei mob

I mob non spawnano con random tick. Appaiono quando il tick server incontra una nuova frontiera di chunk:

// spawn deterministico alle frontiere 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, stessa seed di chunk. Se uno slot mob è libero, lo spawn è deterministico.

Il craft: niente matrici, if/else

// crafting.c, righe 9-347 (semplificato)

// calcola il risultato del craft dal contenuto della griglia
void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // Se il flag 0x80 è alzato, il buffer di craft è usato da una cassa
  if (player->flags & 0x80) { *count = 0; *item = 0; return; }

  // Conta gli slot, trova il primo item, verifica 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 di craft, stivali */
    case 5:  /* picconi, asce, elmi */
    case 7:  /* gambali, compostatori */
    case 8:  /* forno, cassa, corazza */
    case 9:  /* blocchi completi (ferro, oro, ecc.) */
  }
}

Il primo check: se il flag 0x80 è alzato, il buffer di craft è riciclato in puntatore di cassa. Il craft resta bloccato.

Poi, conta gli slot riempiti, nota il primo item, verifica l'identità. Con solo questo, becchi il forno in 4 check:

// forno: 8 di ciottolo identici col centro vuoto
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 una stessa funzione di matching: il materiale determina il risultato.

Interface de craft et coffre dans Bareiron

Le casse: l'hack in vero

L'hack memoria di cui parlano tutti, in vero codice:

// procedures.c, righe 1262-1293

// apri una cassa: punta ai suoi dati nel buffer di craft
if (target == B_chest) {
  // Cerca la voce della cassa nella tabella 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 cassa
    break;
  }
  if (storage_ptr == NULL) return;

  // Terrible memory hack!!
  // Copia il PUNTATORE nella tabella di item di craft del giocatore
  memcpy(player->craft_items, &storage_ptr, sizeof(storage_ptr));
  player->flags |= 0x80;  // blocca il craft

  // Invia l'interfaccia cassa 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 quello. Prende l'indirizzo memoria della voce successiva in block_changes[], lo copia in player->craft_items (che è un uint16_t[9], quindi 18 byte: abbastanza per salvare un puntatore a 32 bit), e alza il flag perché nessuno provi a craftare nel frattempo.

Su ogni clic nell'inventario della cassa:

// packets.c, righe 620-638

// recupera il puntatore cassa dal buffer di craft
uint8_t *storage_ptr;
memcpy(&storage_ptr, player->craft_items, sizeof(storage_ptr));
// storage_ptr punta ormai ai dati della cassa
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 cassa sono salvati a colpi di 3 byte per slot (2 per l'ID, 1 per la quantità), incollati gli uni agli altri nella tabella di blocchi.

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

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 immobili. Lo correliamo
// con l'attività per simulare la fame 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;
}
}

È letteralmente quello. 5 righe. Ogni pacchetto di movimento decrementa la saturazione. Quando la saturazione arriva a zero, la fame cala e si resetta la saturazione. Lo sprint (flag 0x08) raddoppia il consumo.

Zero timer, zero memoria allocata, zero compute dedicato. Un contatore che si decrementa su pacchetti che esistono già.

I danni da caduta

Il sistema di danni più semplice del progetto:

// Quando il giocatore lascia il suolo, salviamo il suo Y
// Quando ritocca il suolo, sottraiamo
degats = dernier_y_au_sol - y_actuel;

Una sottrazione.

Minare e piazzare blocchi

Quando clicchi su un blocco, il pacchetto 0x28 (Player Action) atterra nello switch. L'handler deve determinare che blocco si trova alla posizione, rimuoverlo, e mettere l'item nell'inventario:

// main.c, case 0x28 (semplificato)

// gestisce distruzione e piazzamento blocchi
void handlePlayerAction (int client_fd, uint8_t action, int x, uint8_t y, int z) {
  switch (action) {
    case START_DESTROY_BLOCK: {
      // Determina 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;
      }

      // Aggiungi ai block_changes
      addBlockChange(x, z, y, 0);  // 0 = aria

      // Dai l'item al giocatore (fidati del client)
      addItemToPlayer(client_fd, block_to_item(block), 1);

      // Invia l'aggiornamento al client
      sc_blockChange(client_fd, x, y, z, 0);
      sc_ackBlockChange(client_fd, x, y, z, 0);
      break;
    }
    case PLACE_BLOCK: {
      // Leggi 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 i cambiamenti giocatori:

// priorità ai cambi giocatori, fallback sul terreno
uint8_t getBlockAt (int x, uint8_t y, int z) {
  // Prima verifica i cambiamenti giocatori
  uint8_t change = getBlockChange(x, y, z);
  if (change != 0xFF) return change;

  // Altrimenti, leggi dal terreno generato
  return getTerrainBlock(x, y, z);
}

Priorità ai cambiamenti, fallback sul terreno. Zero dibattito, zero cache, zero overhead. Il getTerrainBlock sotto il cofano, è getHeightAt + gli strati di stone/dirt/grass/coal.

Il forno istantaneo

Il più divertente: il forno non esiste come entità. Se metti del ciottolo nella casella "cottura" e del carbone in "fuel", il risultato appare subito, senza timer né chunk ticking: è solo uno slot di inventario che si svuota quando metti gli item giusti.

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

Il ciclo ESP32: un server MC in 4 KB di stack

// main.c, righe 732-779

#ifdef ESP_PLATFORM

// task FreeRTOS che lancia il server
void bareiron_main (void *pvParameters) {
  main();
  vTaskDelete(NULL);
}

// al WiFi connesso, crea la task server
static void wifi_event_handler (...) {
  if (/* connesso */) {
    xTaskCreate(bareiron_main, "bareiron", 4096, NULL, 5, NULL);
  }
}

// init timer + WiFi, il resto lo gestisce l'event handler
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, crea bareiron_main che chiama il main() standard.

Tutto il codice specifico ESP32 è protetto da #ifdef ESP_PLATFORM. Su PC, tutto questo compila in codice POSIX standard.

Cosa è stato sacrificato

Perché tutto questo stia, ci sono feature vanilla che non esistono:

  • Zero compressione rete: zlib troppo costoso. Il server genera chunk in fretta, ma inviarli è il bottleneck.
  • Zero random tick: gli alberi crescono con bone meal o no. I mob spawnano alle frontiere di chunk.
  • Zero entità item: i blocchi minati vanno diretto nell'inventario. L'animazione è puramente visiva.
  • Nessuna verifica di inventario: fidati del client. 64 diamanti? OK. Un chunk minato in 1 sec? OK. Da usare tra gente di fiducia.
  • Zero luce server: le torce sono inviate dopo tutto il resto, il client calcola.
  • Zero fluidi progressivi: stato finale istantaneo.

Il risultato finale

Ryzen 5 3600: circa 0.5 ms per chunk. ESP32-C3 da 1$: circa 200 ms per chunk. Giocabile.

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

3+ giocatori: arranca. Paragonabile a 2b2t nelle ore di punta, dice l'autore.

Plusieurs joueurs connectés au même serveur Bareiron

La filosofia

p2r3: "Mi piace solo l'idea che questo piccolissimo chip da 1 dollaro 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, poi interpolazione: meno bello, 200x più rapido, zero memoria
  • Matrici di craft, poi matching hardcodato: codice schifoso, zero byte
  • zlib, poi niente: connessione scarsa = morte, ma giocabile
  • Validazione, poi fiducia: zero sicurezza, zero compute

Ogni feature assente permette a un'altra di esistere nei limiti dell'hardware.

I 3 punti da ricordare:

  1. Interpolazione + RNG: 4 punti seminati, 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: l'assenza di compressione, random tick e validazione è scelta voluta: è ciò che permette di stare in 520 KB.
  3. Gli hack schifosi sono i più intelligenti: casse nella tabella di blocchi via memcpy, fame via pacchetti di movimento, forno istantaneo. La soluzione pulita sarebbe costata troppo.

Se il progetto ti interessa, tutto è su GitHub in GPLv3. È C bello sporco, e raramente ho preso tanto piacere a leggere un codice sorgente.

Bareiron, der Minecraft-Server, der auf einem 1$-Mikrocontroller läuft

6800 Zeilen C, null malloc, Perlin Noise ersetzt durch

Einleitung

Einen Minecraft-Server auf einem 1-Dollar-Mikrocontroller laufen lassen klingt wie ein Witz.

Ich stellte mir die Frage, und die Antwort ist ja. Buchstäblich.

Es gibt ein Projekt namens Bareiron, signiert p2r3, und es ist wahrscheinlich eines der faszinierendsten Projekte, die ich in den letzten Jahren in der Minecraft-Welt gesehen habe. Man redet von einem Binary, das in 300 Kilobyte passt, 6800 Zeilen C, null externe Abhängigkeiten, kein malloc, kein Threading, und es läuft auf einem 1-Dollar-ESP32.

ESP32-C3, der Mikrocontroller, der den Server laufen lässt

Unendliche Terraingenerierung. Biome. Höhlen. Craften. Minen. Mobs. Hunger. Truhen. Alles, was du von einem Survival-Server erwartest.

Auf einem Chip, der 0.5 Watt verbraucht und 160 MHz Clock hat.

Zur Größenordnung: Ein Vanilla-Minecraft-Server braucht mehrere GB RAM. Der ESP32-C3 hat 520 KB SRAM (400 verfügbar nach Boot). Prozessoren vor 20 Jahren liefen schon im Gigahertz, dieser cappt bei 160 MHz. Der Faktor zwischen beiden in purer Power ist etwa 20.000.

p2r3 schrieb keinen Minecraft-Server in C, er erfand jeden Baustein des Servers neu, damit er in diese Constraints passt. Wir schauen wie, indem wir den Sourcecode öffnen.

Thumbnail von p2r3s Bareiron-Präsentationsvideo

Das Gehirn des Projekts: speicherlose Terraingenerierung

Das größte Problem, wenn du einen eingebetteten MC-Server machen willst, ist Terraingenerierung.

In Vanilla-Minecraft wird die Welt mit Perlin Noise generiert: Mehrere überlagerte Schichten (Oktaven), 6 biomische Parameter (Temperatur, Feuchte, 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 generierte Chunks zu speichern.

Bareirons Ansatz ist radikal anders. Statt Noise zu stapeln, nutzt er bilineare Interpolation auf 4 von einem deterministischen RNG generierten Punkten.

Du weißt, wenn du ein kleines verpixeltes Bild vergrößerst und die Kanten verschwimmen? Genau das.

// 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 im Chunk
  int rz = z % CHUNK_SIZE;
  uint32_t hash = getChunkHash(_x, _z);
  uint8_t biome = getChunkBiome(_x, _z);
  // Interpolation zwischen 4 Ecken geseedet von Hash + Biom
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

Standard-bilineare Interpolation: 4 Ecken, Gewichte nach Position, ein einziges uint8_t Output. CHUNK_SIZE ist 8, also passiert das in Integer-Multiplikationen, kein Float.

p2r3 zeigt es Schritt für Schritt im Video: Erst die 4 Chunk-Ecken, jede mit einer vom RNG geseedeten Höhe.

Die 4 Chunk-Ecken, jede geseedet vom deterministischen RNG

Dann erzeugt die Interpolation zwischen diesen 4 Punkten eine kontinuierliche Oberfläche.

Anwendung bilinearer Interpolation zwischen den 4 Ecken

Und durch Wiederholen des Patterns auf allen angrenzenden Chunks erhält man ein Terrain, das sich ins Unendliche erstreckt.

Endergebnis: kontinuierliches unregelmäßiges Terrain

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 einzigartigen 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 globale Seed
  return splitmix64(*((uint64_t *)buf));  // Hash
}

Er packt 16 Bit X, 16 Bit Z und 32 Bit Seed in einen 8-Byte-Buffer und schickt alles durch splitmix64. Ergebnis: Ein deterministischer einzigartiger Wert für jede Position, basierend auf der Welt-Seed.

Du kapierst die Power des Mechanismus? Der Server muss das Terrain nicht speichern. Er berechnet neu on the fly, wenn der Spieler in eine neue Zone kommt, und das gibt jedes Mal exakt dasselbe Ergebnis.

Das genutzte splitmix64 ist ein ultraschneller PRNG designed für 64-bit-Hashes:

// 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. Alles passt in direkte Operationen, ohne Tabelle oder Schleife. Es nimmt den 8-Byte-Buffer (X + Z + Seed), behandelt ihn wie eine 64-bit-Ganzzahl und gibt 32 Bit Hash zurück. Deterministisch, schnell, und passt 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, wie mehr Münzwürfe dich näher an 50/50 bringen". In der Praxis ist es die Zahl der Hash-Bits, die er kombiniert:

// worldgen.c, Zeilen 51-115

// Für Plains-Biom : 4 kombinierte Faktoren → regelmäßiges Terrain
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Für Snowy Plains : 2 Faktoren → hügeliger
h = (hash % 5) + ((hash >> 4) % 5);

Jedes Biom wählt, wie viele Bit-Extraktionen es kombiniert. Je mehr, desto mehr stabilisiert sich die Verteilung, wie mehr Münzwürfe, die sich 50/50 annähern. Je weniger, desto stärker die lokalen Variationen.

Unregelmäßiges Terrain, wenige Faktoren, starke Variationen

Mit nur 2 Faktoren produziert Snowy Plains hügeliges, fast bergiges Terrain. Peaks und Täler sind häufig.

Regelmäßiges Terrain, multiple Faktoren, glatte Oberfläche

Mit 4 Faktoren bleiben Plains flach und vorhersehbar. Die Verteilung stabilisiert sich.

Ein Chunk generiert sich in 200 ms auf ESP32, gegen unmessbare Zeit auf selber Hardware mit Perlin Noise, so teuer ist es.

Das Killer-Detail: einen Block abfragen ohne den ganzen Chunk zu generieren

Du spielst, du minst einen Block. Der Server muss wissen, welches Item er dir geben muss. Naiv müsste er dafür den ganzen Chunk generieren.

Mit bilinearer Interpolation fragst du jeden Punkt der Ebene direkt aus Koordinaten ab. Die Chunk-Ecken bekommt man aus der Spielerposition, die Interpolation gibt die Höhe bei jedem Offset. Eine Handvoll Matheoperationen, keine Chunk-Generierung.

p2r3: "Was ich will, ist eine magische Funktion, die mir sagen kann, welcher Block an einer gegebenen Koordinate ist, ohne auf Speicher zuzugreifen oder teure Noise-Maps zu berechnen". Exakt was er machte.

So wird Höhe zu konkreten Blöcken:

// 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 Grass/Dirt/Stone/Deepslate/Bedrock-Schicht. Der Oberflächenblock hängt via biome_top[] vom Biom ab, Grass für Plains, Sand für Wüste. Eine simple If-Kaskade fällt in die richtige Schicht, was Schleife und Switch ersetzt.

Die Höhlen, faulster Mirror

Höhlenhöhe = CAVE_BASE_DEPTH - (Oberflächenhöhe - y);

Er mirrort die Oberflächenhöhe unter Erde. Sieht aus wie große Deepslate-Kavernen. Null Compute, eine Zeile.

Durch Oberflächen-Terrain-Mirror generierte Höhlen

Schema des Terrain-Mirrors zur Höhlengenerierung

Die Erze, XOR-Version

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

Ein Koordinaten-XOR garantiert einen Kandidaten pro Spalte. Der Typ hängt nur von der Höhe ab. Diamanten verstecken sich unter dem tiefsten Punkt der Höhlen, damit Graben nützlich bleibt.

Die Biome als Tile Map

Jedes Biom ist eine kreisförmige Insel in einem Grid, sein Typ bestimmt von einem aus der Seed berechneten Pattern. Gegridet, vorhersehbar und gratis.

Biomkarte als Tile Map, jede Insel ein anderes Biom

Jedes Biom hat seinen eigenen Parametersatz kodiert in Tabellen:

// 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 tiefer
  [BIOME_SNOWY]   = 40,   // am tiefsten
};

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 regelmäßig
  [BIOME_DESERT]  = 3,   // 3 Extraktionen → moderat
  [BIOME_FOREST]  = 4,   // 4 Extraktionen → regelmäßig, hügelig
  [BIOME_TAIGA]   = 3,   // 3 Extraktionen → moderat
  [BIOME_SNOWY]   = 2,   // 2 Extraktionen → sehr hügelig
};

Plains: Höhe 48, 4 Faktoren → sehr flaches Terrain, Gras.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Ergebnis : Variation max ±4 Blöcke

Desert: Höhe 52, 3 Faktoren, Oberflächenblock = Sand. Nie unter Meeresspiegel.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// Ergebnis : Variation max ±6 Blöcke, geclampt auf SEA_LEVEL+1

Forest: Höhe 50, 4 Faktoren wie Plains aber höhere Basis → bewaldete Hügel.

Taiga: Höhe 46, 3 Faktoren → moderate Variationen, kaltes Terrain.

Snowy Plains: Höhe 40, nur 2 Faktoren → am hügeligsten.

h = (hash % 5) + ((hash >> 4) % 5);
// Ergebnis : Variation max ±14 Blöcke

Jedes Biom ist in 3 Tabellen zu 5 Einträgen kodiert: Basishöhe, Oberflächenblock, Faktorenzahl. Wenn getHeightAtFromHash das Biom empfängt, konsultiert es diese Tabellen, um das Terrain anzupassen. 15 Byte Daten, um das ganze Vanilla-Minecraft-Biomsystem zu ersetzen.

Der Biomdetektor nutzt die Seed, um zu bestimmen, welches Biom zu jedem Chunk passt:

// 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 16-Einträge-Pattern, ein von Chunk-Koordinaten geseedeter Index. Das gibt ein repetitives aber visuell kohärentes Grid. 4 Codezeilen, um das ganze biomische Vanilla-Minecraft-Parametersystem zu ersetzen.

getHeightAtFromHash: der Terrain-Assembler

Die Kernfunktion der Generierung kombiniert die 4 biomgeseedeten 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 Hash extrahiert, andere 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);

  // Biom-Bedingung : 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 verschiedenen Shifts aus dem Hash extrahiert. Die Wüste forct das Min über Meeresspiegel, eine Bedingungszeile, die Wasser ohne zusätzliche biomische Berechnung vermeidet.

Bäume und Kakteen: probabilistisches Platzieren

Die Oberflächengenerierung nutzt denselben Chunk-Hash, um zu entscheiden, wo zu pflanzen:

// 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
    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, max 1 Kaktus für Wüste. Der Chunk-Hash ist die einzige Entropiequelle, ein & 7 für Position im Chunk, ein & 3 für Zähler. Alles deterministisch, nichts gespeichert.

generateChunk: alles zusammensetzen

Die Funktion, die alles zusammenfügt, um einen kompletten 8×8×256-Block-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 Chunk-Spalte (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;

      // Spaltenhöhe
      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 ist alles. 3 verschachtelte Schleifen: Für jede Spalte Höhe finden, Blöcke füllen, weiter. Output ist ein uint8_t[16384] (8 × 8 × 256), das den kompletten Chunk darstellt. Der Chunk wird generiert und direkt an den Client geschickt, ohne Caching, Lazy Loading oder Kompression.

Der Speicher: überall statische Arrays

Bareirons Speicherarchitektur ist eingebettetes C in voller Pracht: Nur globale fixgroße Tabellen, kein malloc, keine Hash Maps, keine Linked Lists.

Die Blockänderungen

// globals.h, Zeilen 191-196

typedef struct {
  short x;      // 2 Bytes, limitiert auf 32.000 horizontale Blöcke
  short z;      // 2 Bytes
  uint8_t y;    // 1 Byte, limitiert auf 256 vertikale Blöcke
  uint8_t block; // 1 Byte, limitiert auf 256 Blocktypen
} BlockChange;

20.000 Einträge, also etwa 25.000 Änderungen, das Äquivalent von anderthalb komplett umgegrabenen Chunks. Das block-Feld auf 0xFF markiert einen freien Eintrag. Suche ist linearer Scan:

Speicherlayout der Blocktabelle, 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;
}

Änderung hinzufügen ist so direkt wie 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 Write. Die Tabelle stapelt nur: Kein Sortieren, keine Kompaktierung, kein Speichermanagement. Wenn die Tabelle voll ist, werden neue Änderungen ignoriert, das Terrain kehrt zu generiertem Zustand zurück.

Der Autorenkommentar zum 256-Block-Limit: "Ich habe nicht vor, die leicht patiniert gewachste Kupfertrappe so bald zu implementieren."

Die Mobs: 8 Bytes pro Pipe-Kopf

// globals.h, Zeilen 240-251 (pragma pack(push, 1) um Padding zu eliminieren)

typedef struct {
  uint8_t type;   // 25=Huhn, 28=Kuh, 95=Schwein, 106=Schaf, 145=Zombie
  short x;
  uint8_t y;      // wenn Health=0, wird Y Timer vor Löschung
  short z;
  uint8_t data;   // Bits 0-4 : Health, Bit 5 : Schaf geschoren, Bits 6-7 : Panik-Timer
} MobData;

8 Bytes. Max 16 Slots. Alignment und Padding fliegen raus. Das data-Byte ist ein Haus-Bitfield: 5 Bit Leben, 1 Bit Schur, 2 Bit Panik-Timer. Und wenn ein Mob stirbt, wird das Y-Feld Timer vor Löschung. Speichernutzung auf Bitebene.

Die Spieler: eng gepackt

Spielerdaten nutzen auch #pragma pack(push, 1), Koordinaten in short + uint8_t, Inventare in fixen uint16_t- + uint8_t-Tabellen, und ein flags-Feld, das zugleich Angriffs-Cooldown, Spawn-State, Sneak, Sprint, Eat, Load, Movement-Cooldown und Craft-Lock kodiert. Alles in individuellen Bits.

Die Hauptschleife: while(true) und non-blocking

Der ganze Server läuft auf einer Schleife, einem Thread, null Event-Library.

// main.c, Zeilen 594-720

while (true) {
  task_yield();  // lässt Watchdog auf ESP32 atmen

  // Neue Verbindung akzeptieren (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 wenn Zeit um
  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 : 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);
}

Ein einziger Client wird pro Schleifeniteration behandelt, und ein einziges Paket auf einmal gelesen. Das task_yield() am Schleifenanfang lässt den FreeRTOS-Idle-Task auf ESP32 atmen, ohne das resettet dich der Watchdog-Timer den Chip.

Der Paket-Dispatch ist ein monströser 400-Zeilen-Switch:

// 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 State
    case 0x01:  // Status-Ping
    case 0x02:  // Plugin-Message
    case 0x03:  // Login/Config-Acknowledgment
    case 0x08:  // Chat
    case 0x0B:  // Client-Status (Respawn)
    case 0x11:  // Click Container (handhabt Truhen)
    case 0x19:  // Interact Entity
    case 0x1D..0x20:  // Movement-Pakete (größter Fall)
    case 0x28:  // Player Action (Dig/Place)
    // ... 40+ Fälle
  }
}

Ein Switch kompiliert zu statischer Jump Table, was dynamische Jump Table, Vtable und Map ersetzt. Perfekt für Embedded.

Der 0x1D-0x20-Fall ist der größte, er handhabt Positionsupdates, Fallschaden, Chunk-Grenzüberquerungen, Mob-Spawn, Chunk-Generierung UND Hunger. Alles in einem einzigen großen Fall-through.

Der Bareiron-Servercode, 6800 Zeilen C

Der Server-Tick und Mob-KI

Die handleServerTick-Funktion wird alle 50 ms aufgerufen (20 TPS). Sie handhabt die Welt, während die Hauptschleife sich um Spieler kümmert:

// main.c (vereinfacht)

void handleServerTick (uint32_t delta) {
  // Jeden Mob updaten
  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) {
      // Hostil : 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 auf 2 Blöcke
      if (abs(px - mob->x) <= 2 && abs(pz - mob->z) <= 2)
        damagePlayer(getNearestPlayerId(mob->x, mob->z), 3);
    } else {
      // Passiv : 8 Zufallsrichtungen
      uint8_t dir = getMobDir(mob);
      mob->x += dir_lookup[dir][0];
      mob->z += dir_lookup[dir][1];
      // Richtungswechsel etwa alle 40 Ticks
      if (mob->data >> 6 < 1) setMobDir(mob, rand() & 7);
      mob->data = (mob->data & 0x3F) | ((mob->data - 0x40) & 0xC0);
    }

    // Chunks um Mob wecken
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

Hostile Mob-KI ist ein Koordinatenvergleich. Buchstäblich if (px < x) x--. Der Zombie ignoriert Pathfinding, A* und Obstacle Avoidance: Er justiert X und Z unabhängig zum Spieler, er läuft durch Wände, wenn welche da sind.

Kontaktschaden ist 3 Herzen/Sek. p2r3 wollte ihn hoch, weil fehlendes Pathfinding Zombies leicht kitbar macht.

Die Rüstungsformel ist die von vor dem Combat Update, die simpelstmögliche:

// main.c (vereinfacht)

static uint8_t applyArmor (uint8_t damage, uint16_t armor_value) {
  // Prä-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 3-Herzen-Zombieschlag wird 0.6 Herzen. p2r3 wählte diese alte Formel, weil sie sich in 2 Operationen berechnet, keine Schwellen, keine Kurven, nur linearer Prozentsatz.

Passive Mobs: 8 Richtungen in einer Lookup-Table, Kurswechsel etwa alle 40 Ticks. Das data-Feld kodiert die laufende Richtung in den 2 hochwertigen Bits und den Richtungswechsel-Timer in den 6 restlichen Bits.

Mobs in Bareiron, Zombies, Schweine, Schafe

Der Mob-Respawn

Mobs spawnen nicht mit Random Ticks. Sie erscheinen, wenn der Server-Tick auf eine neue Chunk-Grenze trifft:

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

Derselbe RNG wie Terrain, dieselbe Chunk-Seed. Wenn ein Mob-Slot frei ist, ist der Spawn deterministisch.

Das Craften: keine Matrizen, if/else

// crafting.c, Zeilen 9-347 (vereinfacht)

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // Wenn Flag 0x80 gesetzt, wird Craft-Buffer von Truhe benutzt
  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, Stufen */
    case 4:  /* Werktisch, Boots */
    case 5:  /* Spitzhacken, Äxte, Helme */
    case 7:  /* Beinschienen, Komposter */
    case 8:  /* Ofen, Truhe, Brustpanzer */
    case 9:  /* komplette Blöcke (Eisen, Gold usw.) */
  }
}

Der erste Check: Wenn Flag 0x80 gesetzt, wird Craft-Buffer als Truhenpointer recycelt. Craften bleibt verriegelt.

Danach zählt er gefüllte Slots, notiert erstes Item, prüft Identität. Schon damit matchst du den Ofen in 4 Checks:

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

Für komplexe Formen nutzt er den Erst-Item-Index und prüft relative Position. Rezepte teilen dieselbe Matching-Funktion, das Material bestimmt Ergebnis.

Craft- und Truheninterface in Bareiron

Die Truhen: der Hack live

Der Speichern-Hack, über den alle reden, in echtem Code:

// procedures.c, Zeilen 1262-1293

if (target == B_chest) {
  // Truheneintrag in Blocktabelle 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 Truhenblock
    break;
  }
  if (storage_ptr == NULL) return;

  // Terrible memory hack!!
  // Wir kopieren den POINTER in das Spieler-Craft-Item-Array
  memcpy(player->craft_items, &storage_ptr, sizeof(storage_ptr));
  player->flags |= 0x80;  // verriegelt Craften

  // Truheninterface an Client schicken
  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. Er nimmt die Speicheradresse des folgenden Eintrags in block_changes[], kopiert sie in player->craft_items (das ein uint16_t[9] ist, also 18 Bytes, genug um einen 32-bit-Pointer zu speichern), und hebt das Flag, damit niemand währenddessen craftet.

Bei jedem Klick in Truheninventar:

// packets.c, Zeilen 620-638

uint8_t *storage_ptr;
memcpy(&storage_ptr, player->craft_items, sizeof(storage_ptr));
// storage_ptr zeigt jetzt auf Truhendaten
uint16_t *p_item = (uint16_t *)(storage_ptr + (slot - 41) * 3);
uint8_t *p_count = storage_ptr + (slot - 41) * 3 + 2;

Er holt den Pointer aus dem Craft-Buffer zurück und greift per Offset auf Slots zu. Truhendaten liegen zu 3 Bytes pro Slot (2 für ID, 1 für Menge), aneinandergeklebt in der Blocktabelle.

In Blocktabelle gespeicherte Truhendaten, ein Speichern-Hack

Der Hunger: 5 Zeilen Genie

// main.c, Zeilen 293-305

// Spieler schicken Bewegungspakete ~20/Sek wenn sie
// sich bewegen, viel weniger wenn sie stillstehen. Man korreliert das
// mit Aktivität, um Hunger gratis 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) {  // Sprinten
  player->saturation -= 1;
}
}

Buchstäblich das. 5 Zeilen. Jedes Bewegungspaket dekrementiert Sättigung. Wenn Sättigung null erreicht, sinkt Hunger und man resettet Sättigung. Sprint (Flag 0x08) verdoppelt Drain.

Null Timer, null allokierter Speicher, null dediziertes Compute. Ein Zähler, der auf eh existierenden Paketen dekrementiert.

Der Fallschaden

Das simpelste Schadenssystem des Projekts:

// Wenn Spieler Boden verlässt, speichert man sein Y
// Wenn er Boden wieder berührt, subtrahiert man
Schaden = letztes_y_am_Boden - aktuelles_y;

Eine Subtraktion.

Blöcke minen und platzieren

Wenn du auf einen Block klickst, landet das 0x28-Paket (Player Action) im Switch. Der Handler muss bestimmen, welcher Block an der Position ist, ihn entfernen und das Item ins Inventar legen:

// main.c, Fall 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 geklickter 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 = Luft

      // Item an Spieler geben (trust the client)
      addItemToPlayer(client_fd, block_to_item(block), 1);

      // Update an Client schicken
      sc_blockChange(client_fd, x, y, z, 0);
      sc_ackBlockChange(client_fd, x, y, z, 0);
      break;
    }
    case PLACE_BLOCK: {
      // Blocktyp aus 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 Terraingenerierung UND 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 aus generiertem Terrain lesen
  return getTerrainBlock(x, y, z);
}

Priorität auf Änderungen, Fallback auf Terrain. Null Debatte, null Cache, null Overhead. Das getTerrainBlock darunter ist getHeightAt + Stone/Dirt/Grass/Coal-Schichten.

Der Instant-Ofen

Das Lustigste: Der Ofen existiert nicht als Entity. Wenn du Cobblestone in den "Koch"-Slot und Coal in "Fuel" legst, erscheint das Ergebnis sofort, ohne Timer oder Chunk Ticking: Es ist nur ein Inventarslot, der sich leert, wenn du die richtigen Items legst.

Instant-Ofen, Zutaten reinlegen, sofortiges Ergebnis

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();
  // Rest handhabt Event Handler
}
#endif

Der ganze Server läuft in einem FreeRTOS-Task mit 4096 Bytes Stack. Das ist alles. Der Main-Thread initialisiert nur WiFi und wartet auf Verbindung. Einmal verbunden, spawnt er bareiron_main, das das Standard-main() aufruft.

Aller ESP32-spezifische Code ist von #ifdef ESP_PLATFORM geschützt. Auf PC kompiliert das alles zu Standard-POSIX-Code.

Was geopfert wurde

Damit das alles passt, gibt es Vanilla-Features, die nicht existieren:

  • Null Netzwerkkompression, zlib zu teuer. Der Server generiert Chunks schnell, aber sie zu schicken ist Bottleneck.
  • Null Random Ticks, Bäume wachsen mit Bone Meal oder nicht. Mobs spawnen an Chunk-Grenzen.
  • Null Item-Entity, geminte Blöcke gehen direkt ins Inventar. Animation ist rein visuell.
  • Keine Inventarprüfung, trust the client. 64 Diamanten? OK. Ein Chunk in 1 Sek gemint? OK. Unter vertrauenswürdigen Leuten nutzen.
  • Null Serverlicht, Fackeln werden nach allem Rest geschickt, der Client berechnet.
  • Null progressive Fluide, instantaner Endzustand.

Das Endergebnis

Ryzen 5 3600: ~0.5 ms pro Chunk. 1$-ESP32-C3: ~200 ms pro Chunk. Spielbar.

Chunk-Generierungs-Benchmark, Ryzen vs ESP32

3+ Spieler: Es ruckelt. Vergleichbar mit 2b2t zu Stoßzeiten, laut Autor.

Mehrere Spieler auf demselben Bareiron-Server verbunden

Die Philosophie

p2r3: "Ich mag einfach die Idee, dass dieser winzige 1-Balles-Chip, der 0.5 Watt verbraucht, etwas so Fortgeschrittenes wie Minecraft laufen lassen 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
  • Craft-Matrizen → hardcoded Matching: Hässlicher Code, null Byte
  • zlib → nichts: Miese Verbindung = tot, aber spielbar
  • Validierung → Trust: Null Sicherheit, null Compute

Jedes fehlende Feature erlaubt einem anderen, in Hardware-Limits zu existieren.

Die 3 Punkte zum Mitnehmen :

  1. Interpolation + RNG, 4 geseedete Punkte, unendliches Terrain, null Speicherung, Query ohne Chunk-Regenerierung, 200 ms Generierung. Der Genie-Move, der alles andere erst möglich macht.
  2. Jedes Feature hat Kosten, fehlende Kompression, Random Ticks und Validierung sind eingestandene Wahl: Das erlaubt, in 520 KB zu passen.
  3. Eklige Hacks sind die cleversten, Truhen in Blocktabelle via memcpy, Hunger via Bewegungspakete, Instant-Ofen. Die saubere Lösung wäre zu teuer gewesen.

Wenn dich das Projekt interessiert, alles liegt auf GitHub unter GPLv3. Saumäßig dreckiges C, und ich hatte selten so viel Spaß beim Lesen von Sourcecode.

Bareiron -- сервер Minecraft, крутящийся на микроконтроллере за 1$

6800 строк C, ноль malloc, шум Перлина, заменённый

Введение

Запустить сервер Minecraft на микроконтроллере за доллар звучит как шутка.

Я задался вопросом, и ответ да. Буквально.

Есть проект под названием Bareiron, автор p2r3, и это, наверное, один из самых захватывающих проектов, что я видел в мире Minecraft за последние годы. Речь о бинаре, умещающемся в 300 килобайт, 6800 строк C, ноль внешних зависимостей, без malloc, без трединга, и это крутится на ESP32 за 1 доллар.

ESP32-C3, микроконтроллер, на котором крутится сервер

Бесконечная генерация террейна. Биомы. Пещеры. Крафт. Копка. Мобы. Голод. Сундуки. Всё, чего ждёшь от survival-сервера.

На чипе, потребляющем 0.5 Ватт и имеющем 160 МГц клока.

Для порядка идеи : ванильному серверу Minecraft нужны несколько гигов RAM. ESP32-C3 это 520 КБ SRAM (400 доступно после бута). Процессоры 20 лет назад уже крутились на гигагерцах -- этот упирается в 160 МГц. Фактор между ними в чистой мощи примерно 20 000.

p2r3 не писал сервер Minecraft на C, он переизобрёл каждый кирпич сервера, чтобы уместить в эти лимиты. Смотрим как, открыв исходник.

Миниатюра видео-презентации Bareiron от p2r3

Мозг проекта : генерация террейна без памяти

Самая большая проблема, когда хочешь сделать встроенный MC-сервер, это генерация террейна.

В ванильном Minecraft мир генерируется шумом Перлина : несколько наложенных слоёв (октавы), 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 углами, засиденными 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/shift, умножение, xor/shift, умножение, xor/shift. Всё укладывается в прямые операции, без таблиц и циклов. Он берёт буфер 8 байтов (X + Z + сид), трактует как 64-битное целое и возвращает 32 бита хэша. Детерминировано, быстро, укладывается в 5 строк.

Почему это не шум Перлина

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 -- против неизмеримого времени на том же железе с шумом Перлина, настолько это дорого.

Убивающая деталь : опросить блок, не генерируя весь чанк

Ты играешь, копаешь блок. Сервер должен знать, какой предмет тебе дать. Наивно надо бы сгенерировать весь чанк ради этого.

С билинейной интерполяцией опрашиваешь любую точку плоскости прямо из координат. Углы чанка получаются из позиции игрока, интерполяция даёт высоту на любом офсете. Горсть матопераций, без генерации чанка.

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 для пустыни. Простой каскад if падает в правильный слой, заменяя цикл и switch.

Пещеры, ленивейшее зеркало

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 координат гарантирует кандидата на колонку. Тип зависит только от высоты. Алмазы спрятаны под самой низкой точкой пещер, чтобы копание оставалось полезным.

Биомы в 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), представляющий полный чанк. Чанк генерируется и отправляется прямо клиенту, без кэширования, lazy loading или компрессии.

Хранение : статичные массивы повсюду

Архитектура памяти Bareiron это встроенный C во всей красе : только глобальные массивы фиксированного размера, ни malloc, ни hash maps, ни связных списков.

Изменения блоков

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

Добавить изменение так же прямо, как поиск :

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=chicken, 28=cow, 95=pig, 106=sheep, 145=zombie
  short x;
  uint8_t y;      // если health=0, Y становится таймером до удаления
  short z;
  uint8_t data;   // биты 0-4: health, бит 5: sheep sheared, биты 6-7: panic timer
} MobData;

8 байтов. Макс 16 слотов. Выравнивание и паддинг идут лесом. Байт data это домашний bitfield : 5 бит жизни, 1 бит стрижки, 2 бита таймера паники. А когда моб умирает, поле Y становится таймером до удаления. Переиспользование памяти на уровне бита.

Игроки : упакованы плотно

Данные игроков тоже используют #pragma pack(push, 1) -- координаты в short + uint8_t, инвентари в фиксированных массивах uint16_t + uint8_t, а поле flags, кодирующее одновременно кулдаун атаки, состояние спавна, sneak, sprint, eat, load, movement cooldown и лок крафта. Всё это в отдельных битах.

Главный цикл : while(true) и неблокирующее

Весь сервер крутится на одном цикле, одном треде, ноль event library.

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

  // Тик сервера, если время вышло
  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;

  // Читать заголовок пакета : 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() в начале цикла даёт дышать idle-задаче FreeRTOS на ESP32 -- без этого таймер вотчдога ресетнет чип.

Диспатч пакетов это монструозный 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 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+ случаев
  }
}

Switch компилируется в статичную jump table, заменяющую динамическую jump table, vtable и map. Идеально для встроенного.

Случай 0x1D-0x20 самый большой -- он управляет обновлениями позиции, уроном падения, пересечениями границ чанков, спавном мобов, генерацией чанков И голодом. Всё в одном большом fall-through.

Код сервера 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--. Зомби игнорирует патфайндинг, A* и обход препятствий : он подстраивает X и Z независимо к игроку -- проходит сквозь стены, если есть.

Контактный урон на 3 сердца/сек. p2r3 захотел высоким, потому что отсутствие патфайндинга делает зомби лёгкими для кайта.

Формула брони та, что до 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 table, смена курса примерно каждые 40 тиков. Поле data кодирует текущее направление в 2 старших битах, а таймер смены направления в остальных 6 битах.

Мобы в Bareiron -- зомби, свиньи, овцы

Респавн мобов

Мобы не спавнятся random ticks. Они появляются, когда тик сервера встречает новую границу чанка :

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;

  // Ужасный хак памяти!!
  // Копируем УКАЗАТЕЛЬ в таблицу предметов крафта игрока
  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) удваивает drain.

Ноль таймера, ноль аллоцированной памяти, ноль выделенных вычислений. Счётчик, декрементирующийся на уже существующих пакетах.

Урон падения

Простейшая система урона проекта :

// Когда игрок покидает землю, храним его 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);
}

Приоритет изменениям, fallback на террейн. Ноль споров, ноль кэша, ноль оверхеда. getTerrainBlock под капотом это getHeightAt + слои stone/dirt/grass/coal.

Мгновенная печь

Самое смешное : печи не существует как энтити. Если кладёшь cobblestone в клетку "готовки", а 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 байтами стека. Всё. Главный main thread только инициализирует WiFi и ждёт соединения. После подключения он спавнит bareiron_main, зовущий стандартный main().

Весь специфичный для ESP32 код защищён #ifdef ESP_PLATFORM. На ПК всё это компилируется в стандартный POSIX-код.

Что пожертвовали

Чтобы всё это уместилось, ванильных функций не существует :

  • Ноль сетевой компрессии -- zlib слишком дорог. Сервер генерирует чанки быстро, но их отправка это bottleneck.
  • Ноль random ticks -- деревья растут с bone meal или никак. Мобы спавнятся на границах чанков.
  • Ноль item-энтити -- добытые блоки идут прямо в инвентарь. Анимация чисто визуальная.
  • Никакой проверки инвентаря -- доверяем клиенту. 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'."

Каждая строка это tradeoff :

  • Шум Перлина → интерполяция : менее красиво, в 200 раз быстрее, ноль памяти
  • Матрицы крафта → захардкоженный матчинг : грязный код, ноль байтов
  • zlib → ничего : плохая связь = смерть, но играбельно
  • Валидация → доверие : ноль безопасности, ноль вычислений

Каждая отсутствующая функция позволяет существовать другой в лимитах железа.

3 пункта запомнить :

  1. Интерполяция + RNG -- 4 засиденные точки, бесконечный террейн, ноль хранения, опрос без регенерации чанка, 200 мс генерации. Ход гения, делающий возможным всё остальное.
  2. Каждая функция стоит -- Отсутствие компрессии, random ticks и валидации осознанный выбор : именно он позволяет уместиться в 520 КБ.
  3. Грязные хаки самые умные -- Сундуки в таблице блоков через memcpy, голод пакетами движения, мгновенная печь. Чистое решение было бы слишком дорогим.

Если проект интересен, всё на GitHub под GPLv3. Это грязный хороший C, и я редко получал столько удовольствия от чтения исходника.

Bareiron: el servidor Minecraft que corre en un microcontrolador a 1$

6800 líneas de C, cero malloc, ruido Perlin reemplazado por interpolación bilineal, biomas en tile map, y todo en un chip a 1$.

Introducción

Hacer correr un servidor Minecraft en un microcontrolador a 1 dólar suena a chiste.

Me hice la pregunta, y la respuesta es sí. Literalmente.

Hay un proyecto que se llama Bareiron, firmado p2r3, y es probablemente uno de los proyectos más fascinantes que he visto en el mundo 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 a 1 dólar.

ESP32-C3, el microcontrolador que corre el servidor

Generación de terreno infinita. Biomas. Cuevas. Crafteo. Mina. Mobs. Hambre. Cofres. Todo lo que esperas de un servidor survival.

En un chip que consume 0.5 Watt y tiene 160 MHz de reloj.

Para dar un orden de idea: un servidor Minecraft vanilla necesita varios gigas de RAM. La ESP32-C3 son 520 KB de SRAM (400 disponibles tras el boot). Los procesadores hace 20 años ya corrían en gigahertz: este topa en 160 MHz. El factor entre ambos en potencia pura es unas 20 000 veces.

p2r3 no escribió un servidor Minecraft en C, reinventó cada pieza del servidor para que quepa en esas restricciones. Vamos a ver cómo, abriendo el código fuente.

Miniatura del vídeo de presentación de Bareiron por p2r3

El cerebro del proyecto: una generación de terreno sin memoria

El mayor problema cuando quieres hacer un servidor MC embebido es la generación de terreno.

En Minecraft vanilla, el mundo se genera con ruido Perlin: varias capas superpuestas (octavas), 6 parámetros biómicos (temperatura, humedad, continentalidad, erosión, weirdness, profundidad), y todo un sistema de caché para no tener que recalcular todo cada vez.

El resultado es magnífico. Pero es caro en cálculo, y ocupa RAM para guardar los chunks generados.

El enfoque de Bareiron es radicalmente distinto. En vez 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 de 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 con seed 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 en salida. CHUNK_SIZE es 8, así que se hace en multiplicaciones enteras, sin float.

p2r3 lo muestra paso a paso en el vídeo: primero las 4 esquinas del chunk, cada una con una altura con seed por el RNG.

Las 4 esquinas del chunk, cada una con seed por el RNG determinista

Luego la interpolación entre esos 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, obtienes un terreno que se extiende al infinito.

Resultado final: terreno irregular continuo

El RNG determinista

La clave que hace todo eso posible es el seeding. 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 búfer de 8 bytes, y pasa el todo por splitmix64. Resultado: un valor determinista único para cada posición, basado en la seed del mundo.

¿Captas la potencia del mecanismo? El servidor no necesita guardar el terreno. Recalcula al vuelo cuando el jugador llega a una zona nueva, y da exactamente el mismo resultado cada vez.

El splitmix64 usado es un prng ultrarrápido diseñado para los hash 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. Todo cabe en operaciones directas, sin tabla ni bucle. Coge el búfer de 8 bytes (X + Z + seed), lo trata como un entero 64 bits, y devuelve 32 bits de hash. Es determinista, rápido, y cabe en 5 líneas.

Por qué no es ruido Perlin

p2r3 lo dice él mismo en el vídeo: "cuantos más dígitos del número aleatorio añades, más regular se vuelve el terreno, como cuantos más lanzamientos de moneda te acercan al 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 acercan al 50/50. Cuantas menos, más fuertes 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 huecos son frecuentes.

Terreno regular: factores múltiples, superficie lisa

Con 4 factores, las llanuras quedan planas y previsibles. La distribución se estabiliza.

Un chunk se genera en 200 ms en ESP32: contra un tiempo no medible en el mismo hardware con ruido Perlin de lo caro que es.

El detalle que mata: interrogar un bloque sin generar todo el chunk

Juegas, minas un bloque. El servidor tiene que saber qué item darte. Ingenuamente, habría que generar todo el chunk para eso.

Con la interpolación bilineal, interrogas cualquier punto del plano directo 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í la altura se vuelve 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 vía biome_top[]: grass para las llanuras, sand para el desierto. Una simple cascada de if cae en la capa buena, lo que reemplaza bucle y switch.

Las cuevas, el espejo más perezoso

altitud_cueva = CAVE_BASE_DEPTH - (altura_superficie - y);

Espeja la altura de la superficie bajo tierra. Se parece a las grandes cavidades deepslate. Cero cómputo, una línea.

Cuevas generadas por espejo del terreno de superficie

Esquema del espejo de terreno para generar las 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 excavar 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, previsible, y gratis.

Mapa de los biomas en tile map: cada isla es un bioma distinto

Cada bioma tiene su propio juego de parámetros codificado en tablas:

// 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 medias
  [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 máx

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 máx, clamp 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 máx

Cada bioma se codifica en 3 tablas de 5 entradas: altura base, bloque de superficie, número de factores. Cuando getHeightAtFromHash recibe el bioma, consulta esas tablas para ajustar el terreno. 15 bytes de datos para reemplazar todo el sistema de biomas de Minecraft.

El detector de bioma 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 con seed 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 biómicos de Minecraft vanilla.

getHeightAtFromHash: el ensamblador de terreno

La función en el corazón de la generación combina las 4 esquinas con seed 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 distinta 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 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 distintos. El desierto fuerza el mín por encima del nivel del mar: una línea de restricción que evita el agua sin cálculo biómico adicional.

Árboles y cactus: colocación probabilista

La generación de superficie usa el mismo hash de 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, 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 : hash determina si ponemos 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 máximo para el desierto. El hash del chunk es la única fuente de entropía: un & 7 para la posición en el chunk, un & 3 para el contador. Todo es determinista, nada se guarda.

generateChunk: ensamblarlo todo

La función que lo 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. El chunk se genera y luego se envía directo al cliente, sin pasar por caché, lazy loading ni compresión.

El almacenamiento: arrays estáticos en todas partes

La arquitectura memoria de Bareiron es C embebido en todo su esplendor: solo tablas globales de tamaño fijo, ni malloc, ni hash maps, ni listas enlazadas.

Los cambios de bloques

// globals.h, líneas 191-196

typedef struct {
  short x;      // 2 bytes: límite a 32 000 bloques horizontal
  short z;      // 2 bytes
  uint8_t y;    // 1 byte: límite a 256 bloques vertical
  uint8_t block; // 1 byte: límite a 256 tipos de bloques
} BlockChange;

20 000 entradas, o sea unos 25 000 cambios: el equivalente a un chunk y medio totalmente excavado. El campo block a 0xFF marca una entrada libre. La búsqueda es un scan lineal:

Layout memoria de la tabla 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;  // skip chest data
    #endif
  }
  return 0xFF;
}

Añadir un cambio es tan directo como la búsqueda:

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. La tabla se contenta con apilar: ningún orden, ninguna compactación, ninguna gestión memoria. Cuando la tabla está llena, los nuevos cambios se ignoran: el terreno vuelve a su estado generado.

El comentario del autor sobre el límite a 256 bloques: "no pienso implementar las escaleras en cobre ligeramente patinado encerado en breve."

Los mobs: 8 bytes por cabeza de tubo

// 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 vuelve un timer antes de borrar
  short z;
  uint8_t data;   // bits 0-4: health, bit 5: sheep sheared, bits 6-7: panic timer
} MobData;

8 bytes. 16 huecos máx. La alineación y el padding pasan a la basura. El byte data es un bitfield casero: 5 bits de vida, 1 bit de esquila, 2 bits de timer de pánico. Y cuando un mob muere, el campo Y se vuelve un timer antes de borrar. Reutilización de memoria a nivel de bit.

Los jugadores: empaquetado prieto

Los datos jugadores usan #pragma pack(push, 1) también: coordenadas en short + uint8_t, inventarios en tablas fijas 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 gira sobre un bucle, un hilo, cero event library.

// main.c, líneas 594-720

while (true) {
  task_yield();  // deja respirar el 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 servidor si el tiempo se cumplió
  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 la cabecera 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);
}

Un solo cliente se procesa por iteración del bucle, y un solo paquete se lee a la vez. El task_yield() al inicio del bucle deja la FreeRTOS idle task respirar en ESP32: sin eso, el watchdog timer te resetea el chip.

El dispatch de los 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 gordo)
    case 0x28:  // Player action (dig/place)
    // ... 40+ casos
  }
}

Un switch compila en jump table estática, lo que reemplaza jump table dinámica, vtable y map. Perfecto para embebido.

El caso 0x1D-0x20 es el más gordo: gestiona las actualizaciones de posición, los daños de 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 servidor tick 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ños de 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--. El zombi ignora pathfinding, A* y obstacle avoidance: ajusta X y Z independientemente hacia el jugador: atraviesa los muros si los hay.

Los daños de contacto están a 3 corazones/seg. p2r3 lo quiso alto porque la ausencia de pathfinding hace los zombis fáciles de kitear.

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, máx 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 zombi a 3 corazones se vuelve 0.6 corazones. p2r3 eligió esa 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 en curso en los 2 bits de peso fuerte, y el timer de cambio de dirección en los 6 bits restantes.

Mobs en Bareiron: zombis, cerdos, ovejas

El respawn de los mobs

Los mobs no spawnean con random ticks. Aparecen cuando el servidor tick 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 de chunk. Si un hueco de mob está libre, el spawn es determinista.

El crafteo: sin matrices, if/else

// crafting.c, líneas 9-347 (simplificado)

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // Si el flag 0x80 está levantado, el búfer de crafteo lo usa un cofre
  if (player->flags & 0x80) { *count = 0; *item = 0; return; }

  // Contar los slots, encontrar el primer item, comprobar 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:  /* tablas, 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.) */
  }
}

El primer check: si el flag 0x80 está levantado, el búfer de crafteo se recicla como puntero de cofre. El crafteo queda bloqueado.

Luego cuenta los slots llenos, anota el primer item, comprueba 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 checkea 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 memoria del que todo el mundo habla, en código de verdad:

// procedures.c, líneas 1262-1293

if (target == B_chest) {
  // Buscar la entrada del cofre en la tabla de los 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 tras el bloque cofre
    break;
  }
  if (storage_ptr == NULL) return;

  // Terrible memory hack!!
  // Copiamos el PUNTERO en la tabla de items de crafteo del jugador
  memcpy(player->craft_items, &storage_ptr, sizeof(storage_ptr));
  player->flags |= 0x80;  // lockea el crafteo

  // Enviar la interfaz 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. Coge la dirección memoria de la entrada siguiente en block_changes[], la copia en player->craft_items (que es un uint16_t[9], o sea 18 bytes: bastante para guardar un puntero 32 bits), y levanta el flag para que nadie intente craftear mientras.

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 hacia 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 búfer de crafteo, y accede a los slots con un offset. Los datos cofre se guardan a razón de 3 bytes por slot (2 para el ID, 1 para la cantidad), pegados unos a otros en la tabla de bloques.

Datos de cofre guardados en la tabla de bloques: un hack 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. Lo correlacionamos
// con la actividad para simular el hambre 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;
}
}

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 reseteamos la saturación. El sprint (flag 0x08) dobla el drenaje.

Cero timer, cero memoria asignada, cero cómputo dedicado. Un contador que se decrementa sobre paquetes que ya existen.

Los daños de caída

El sistema de daños más simple del proyecto:

// Cuando el jugador deja el suelo, guardamos su Y
// Cuando vuelve a tocar el suelo, restamos
daños = última_y_en_suelo - y_actual;

Una resta.

Minar y colocar bloques

Cuando haces clic en un bloque, el paquete 0x28 (Player Action) aterriza en el switch. El handler tiene que determinar qué bloque hay en la posición, quitarlo, y meter el item en el inventario:

// main.c, caso 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 clicada
      uint8_t block = getBlockAt(x, y, z);

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

      // Añadir a los block_changes
      addBlockChange(x, z, y, 0);  // 0 = aire

      // 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 jugadores:

uint8_t getBlockAt (int x, uint8_t y, int z) {
  // Primero comprobar los cambios 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 divertido: el horno no existe como entidad. Si pones cobblestone en la casilla "cocción" y coal en "fuel", el resultado aparece de inmediato, sin timer ni chunk ticking: es solo un slot de inventario que se vacía cuando metes los items buenos.

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 main thread principal solo inicializa el WiFi y espera una conexión. Una vez conectado, spawnea bareiron_main que llama al main() estándar.

Todo el código específico ESP32 está protegido por #ifdef ESP_PLATFORM. En PC, todo eso compila en código POSIX estándar.

Lo sacrificado

Para que todo eso quepa, hay features vanilla que no existen:

  • Cero compresión red: zlib demasiado caro. El servidor genera chunks rápido, pero enviarlos es el bottleneck.
  • Cero random ticks: los árboles crecen con bone meal o no. Los mobs spawnean en las fronteras de chunk.
  • Cero entidad item: los bloques minados van directo al inventario. La animación es puramente visual.
  • Ninguna comprobación de inventario: trust the client. ¿64 diamantes? OK. ¿Un chunk minado en 1 seg? OK. Para usar entre gente de confianza.
  • Cero luz servidor: las antorchas se envían tras todo lo demás, el cliente calcula.
  • Cero fluidos progresivos: estado final instantáneo.

El resultado final

Ryzen 5 3600: ~0.5 ms por chunk. ESP32-C3 a 1$: ~200 ms por chunk. Jugable.

Benchmark de generación de chunks: Ryzen vs ESP32

3+ jugadores: va lento. Comparable a 2b2t en horas punta, dice el autor.

Varios jugadores conectados al mismo servidor Bareiron

La filosofía

p2r3: "Me encanta la idea de que ese chip tan pequeño a 1 dólar que consume 0.5 Watt pueda correr algo tan avanzado como Minecraft. Science isn't about 'why', it's about 'why not'."

Cada línea es un tradeoff:

  • Ruido Perlin → interpolación: menos bonito, 200x más rápido, cero memoria
  • Matrices de crafteo → matching hardcodeado: código guarro, cero byte
  • zlib → nada: conexión podrida = muerte, pero jugable
  • Validación → trust: cero seguridad, cero cómputo

Cada feature ausente permite que otra exista dentro de los límites del hardware.

Las 3 cosas a recordar:

  1. Interpolación + RNG: 4 puntos con seed, terreno infinito, cero almacenamiento, query sin regenerar el chunk, 200 ms de generación. Es el move de genio que hace todo lo demás posible.
  2. Cada feature tiene un coste: La ausencia de compresión, de random ticks y de validación es una decisión asumida: es lo que permite caber en 520 KB.
  3. Los hacks guarros son los más listos: Cofres en la tabla de bloques vía 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 en GPLv3. Es C bien guarro, y rara vez disfruté tanto leyendo un código fuente.

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:

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 bilinear

Pendahuluan

Menjalankan server Minecraft di mikrokontroler satu dolar terdengar seperti lelucon.

Saya bertanya pada diri sendiri, dan jawabannya ya. Secara harfiah.

Ada proyek bernama Bareiron, karya p2r3, dan ini mungkin salah satu proyek paling menarik yang pernah saya lihat di dunia Minecraft beberapa tahun terakhir. Kita bicara biner yang muat dalam 300 kilobyte, 6800 baris C, nol dependensi eksternal, tanpa malloc, tanpa threading, dan berjalan di ESP32 seharga 1 dolar.

ESP32-C3, mikrokontroler yang menjalankan server

Pembangkitan medan tak terbatas. Biome. Gua. Crafting. Menambang. Mob. Lapar. Peti. Semua yang kamu harapkan dari server survival.

Di chip yang mengonsumsi 0.5 Watt dan berclock 160 MHz.

Sebagai gambaran : server Minecraft vanilla butuh beberapa giga RAM. ESP32-C3 itu 520 KB SRAM (400 tersedia setelah boot). Prosesor 20 tahun lalu sudah berjalan dalam gigahertz -- yang ini mentok di 160 MHz. Faktor keduanya dalam kekuatan murni sekitar 20 000.

p2r3 tidak menulis server Minecraft dalam C, ia menemukan ulang tiap bata server supaya muat dalam batasan ini. Kita akan melihat caranya, dengan membuka kode sumber.

Thumbnail video presentasi Bareiron oleh p2r3

Otak proyek : pembangkitan medan tanpa memori

Masalah terbesar ketika kamu ingin membuat server MC tertanam adalah pembangkitan medan.

Di Minecraft vanilla, dunia dibangkitkan dengan Perlin noise : beberapa lapisan bertumpuk (oktaf), 6 parameter biomik (suhu, kelembapan, kontinentalitas, erosi, weirdness, kedalaman), dan seluruh sistem caching supaya tidak harus menghitung ulang semuanya setiap kali.

Hasilnya indah. Tapi mahal di komputasi, dan memakan RAM untuk menyimpan chunk yang dibangkitkan.

Pendekatan Bareiron sangat berbeda. Alih-alih menumpuk noise, ia memakai interpolasi bilinear pada 4 titik yang dibangkitkan oleh RNG deterministik.

Kamu tahu ketika kamu memperbesar gambar kecil berpiksel dan tepinya jadi buram? Tepat itulah.

// 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);  // koordinat chunk
  int _z = floor(z / CHUNK_SIZE);
  int rx = x % CHUNK_SIZE;          // offset di dalam chunk
  int rz = z % CHUNK_SIZE;
  uint32_t hash = getChunkHash(_x, _z);
  uint8_t biome = getChunkBiome(_x, _z);
  // interpolasi antara 4 sudut yang di seed hash + biome
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

Interpolasi bilinear standar : 4 sudut, bobot menurut posisi, satu uint8_t sebagai keluaran. CHUNK_SIZE adalah 8, jadi ini dilakukan dalam perkalian integer, tanpa float.

p2r3 menunjukkannya tahap demi tahap di video : pertama 4 sudut chunk, masing-masing dengan ketinggian yang di seed RNG.

4 sudut chunk, masing-masing di seed RNG deterministik

Lalu interpolasi antara 4 titik ini menciptakan permukaan kontinu.

Penerapan interpolasi bilinear antara 4 sudut

Dan dengan mengulangi pola pada semua chunk yang bersebelahan, kita mendapatkan medan yang membentang sampai tak terbatas.

Hasil akhir : medan tak 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 tapi bisa direproduksi.

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

Ia mempacking 16 bit X, 16 bit Z, dan 32 bit seed ke buffer 8 byte, dan melewatkan semuanya ke splitmix64. Hasil : nilai deterministik unik untuk tiap posisi, berdasarkan seed dunia.

Kamu menangkap kekuatan mekanismenya? Server tidak perlu menyimpan medan. Ia menghitung ulang langsung ketika pemain tiba di zona baru, dan itu memberikan tepat hasil yang sama setiap kali.

splitmix64 yang dipakai 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 : penjumlahan, xor/shift, perkalian, xor/shift, perkalian, xor/shift. Semuanya muat dalam operasi langsung, tanpa tabel maupun loop. Ia mengambil buffer 8 byte (X + Z + seed), memperlakukannya sebagai integer 64 bit, dan mengembalikan 32 bit hash. Ini deterministik, cepat, dan muat dalam 5 baris.

Kenapa ini bukan Perlin noise

p2r3 mengatakannya sendiri di video : "makin banyak digit angka acak yang kamu tambahkan, makin teratur medannya, seperti makin banyak lemparan koin mendekatkanmu ke 50/50". Dalam praktik, ini jumlah bit hash yang ia gabungkan :

// worldgen.c, baris 51-115

// Untuk biome plains : 4 faktor digabung → medan 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 ekstraksi bit yang ia gabungkan. Makin banyak, makin stabil distribusinya -- seperti makin banyak lemparan koin yang mendekati 50/50. Makin sedikit, makin kuat variasi lokalnya.

Medan tak beraturan -- sedikit faktor, variasi kuat

Dengan hanya 2 faktor, snowy plains menghasilkan medan berbukit, nyaris bergunung. Puncak dan lembah sering.

Medan teratur -- banyak faktor, permukaan halus

Dengan 4 faktor, dataran tetap datar dan bisa diprediksi. Distribusinya stabil.

Satu chunk dibangkitkan dalam 200 ms di ESP32 -- lawan waktu yang tidak terukur di perangkat keras yang sama dengan Perlin noise saking mahalnya.

Detail yang membunuh : menanyai blok tanpa membangkitkan seluruh chunk

Kamu bermain, kamu menambang blok. Server harus tahu item apa yang diberikannya. Secara naif, ia harus membangkitkan seluruh chunk untuk itu.

Dengan interpolasi bilinear, kamu menanyai titik mana pun di bidang langsung dari koordinat. Sudut chunk didapat dari posisi pemain, interpolasi memberimu ketinggian pada offset mana pun. Segenggam operasi matematika, tanpa pembangkitan chunk.

p2r3 : "yang saya inginkan adalah fungsi ajaib yang bisa memberi tahu blok apa yang ada pada koordinat tertentu, tanpa mengakses memori maupun menghitung peta noise yang mahal". Tepat itulah yang ia buat.

Begini cara ketinggian 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. Lapisan grass/dirt/stone/deepslate/bedrock. Blok permukaan bergantung biome lewat biome_top[] -- grass untuk dataran, sand untuk gurun. Kaskade if sederhana jatuh ke lapisan yang benar, yang menggantikan loop dan switch.

Gua, cermin paling malas

altitude_grotte = CAVE_BASE_DEPTH - (hauteur_surface - y);

Ia mencerminkan ketinggian permukaan di bawah tanah. Itu menyerupai rongga besar deepslate. Nol komputasi, satu baris.

Gua yang dibangkitkan lewat cermin medan permukaan

Skema cermin medan untuk membangkitkan gua

Bijih, versi XOR

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

Satu XOR koordinat menjamin satu kandidat per kolom. Tipenya hanya bergantung ketinggian. Berlian disembunyikan di bawah titik terendah gua supaya menggali tetap berguna.

Biome dalam tile map

Setiap biome adalah pulau melingkar dalam kisi, tipenya ditentukan pola yang dihitung dari seed. Terkisi, bisa diprediksi, dan gratis.

Peta biome dalam tile map -- setiap pulau adalah biome berbeda

Setiap biome punya himpunan parameter sendiri yang dienkode dalam tabel :

// worldgen.c (disederhanakan)

static const uint8_t biome_base[] = {
  [BIOME_PLAINS]  = 48,   // ketinggian dasar : 48
  [BIOME_DESERT]  = 52,   // sedikit lebih tinggi
  [BIOME_FOREST]  = 50,   // di antara keduanya
  [BIOME_TAIGA]   = 46,   // sedikit lebih rendah
  [BIOME_SNOWY]   = 40,   // paling rendah
};

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 → sedang
  [BIOME_FOREST]  = 4,   // 4 ekstraksi → teratur, bergelombang
  [BIOME_TAIGA]   = 3,   // 3 ekstraksi → sedang
  [BIOME_SNOWY]   = 2,   // 2 ekstraksi → sangat berbukit
};

Plains : ketinggian 48, 4 faktor → medan sangat datar, rumput.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Hasil : variasi maks ±4 blok

Desert : ketinggian 52, 3 faktor, blok permukaan = pasir. Tidak pernah di bawah permukaan laut.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3);
// Hasil : variasi maks ±6 blok, dijepit ke SEA_LEVEL+1

Forest : ketinggian 50, 4 faktor seperti plains tapi dasar lebih tinggi → bukit berhutan.

Taiga : ketinggian 46, 3 faktor → variasi sedang, medan dingin.

Snowy plains : ketinggian 40, hanya 2 faktor → paling berbukit.

h = (hash % 5) + ((hash >> 4) % 5);
// Hasil : variasi maks ±14 blok

Setiap biome dienkode dalam 3 tabel 5 entri : ketinggian dasar, blok permukaan, jumlah faktor. Ketika getHeightAtFromHash menerima biome, ia berkonsultasi tabel ini untuk menyesuaikan medan. 15 byte data untuk menggantikan seluruh sistem biome Minecraft vanilla.

Detektor biome memakai seed untuk menentukan biome mana yang cocok untuk tiap 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, indeks yang di seed koordinat chunk. Itu memberikan kisi berulang tapi koheren secara visual. 4 baris kode untuk menggantikan seluruh sistem parameter biomik Minecraft vanilla.

getHeightAtFromHash : perakit medan

Fungsi di jantung pembangkitan menggabung 4 sudut yang di seed 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);

  // Batasan biome : gurun 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 punya biome_base yang menggeser ketinggian acuan, dan 4 sudut diekstrak dari hash dengan geseran berbeda. Gurun memaksa min di atas permukaan laut -- satu baris batasan yang mencegah air tanpa komputasi biomik tambahan.

Pohon dan kaktus : penempatan probabilistik

Pembangkitan permukaan memakai 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 menaruh 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, 1 kaktus maks untuk gurun. Hash chunk satu-satunya sumber entropi -- & 7 untuk posisi dalam chunk, & 3 untuk penghitung. Semuanya deterministik, tidak ada yang disimpan.

generateChunk : merakit semuanya

Fungsi yang menyatukan 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;

      // Ketinggian kolom
      uint8_t height = getHeightAt(wx, wz);

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

  // Menambahkan elemen permukaan (pohon, kaktus)
  genFoliage(chunk, cx, cz, hash, biome);
}

Itu saja. 3 loop bersarang : untuk setiap kolom, menemukan ketinggian, mengisi blok, lanjut ke berikutnya. Keluarannya adalah uint8_t[16384] (8 × 8 × 256) yang mewakili chunk lengkap. Chunk dibangkitkan lalu dikirim langsung ke klien, tanpa lewat caching, lazy loading atau kompresi.

Penyimpanan : array statis di mana-mana

Arsitektur memori Bareiron adalah C tertanam dalam segala kemegahannya : hanya tabel global berukuran tetap, tanpa malloc, tanpa hash map, tanpa linked list.

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, yaitu sekitar 25 000 perubahan -- setara satu setengah chunk yang seluruhnya digali. Kolom block bernilai 0xFF menandai entri bebas. Pencariannya pemindaian linear :

Tata letak memori tabel 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;  // lewati data peti
    #endif
  }
  return 0xFF;
}

Menambahkan perubahan sedirect pencariannya :

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

Satu penghitung, satu indeks, satu penulisan. Tabel hanya menumpuk : tanpa pengurutan, tanpa pemadatan, tanpa manajemen memori. Ketika tabel penuh, perubahan baru diabaikan -- medan kembali ke keadaan terbangkitnya.

Komentar penulis soal batas 256 blok : "saya tidak berencana mengimplementasikan tangga tembaga yang sedikit terpatina dan dililin dalam waktu dekat."

Mob : 8 byte per kepala pipa

// 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;      // kalau health=0, Y menjadi timer sebelum penghapusan
  short z;
  uint8_t data;   // bit 0-4: health, bit 5: sheep sheared, bit 6-7: panic timer
} MobData;

8 byte. 16 slot maks. Alignment dan padding dibuang. Byte data adalah bitfield buatan sendiri : 5 bit nyawa, 1 bit cukur, 2 bit timer panik. Dan ketika mob mati, kolom Y menjadi timer sebelum penghapusan. Pemakaian ulang memori di tingkat bit.

Pemain : dipacking rapat

Data pemain memakai #pragma pack(push, 1) juga -- koordinat dalam short + uint8_t, inventaris dalam tabel tetap uint16_t + uint8_t, dan kolom flags yang mengenkode sekaligus cooldown serangan, keadaan spawn, sneak, sprint, eat, load, movement cooldown, dan lock craft. Semua itu dalam bit individual.

Loop utama : while(true) dan non-blokir

Seluruh server berjalan dalam satu loop, satu thread, nol event library.

// main.c, baris 594-720

while (true) {
  task_yield();  // memberi napas watchdog di ESP32

  // Menerima koneksi baru (non-blokir)
  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 kalau waktunya tiba
  if (get_program_time() - last_tick_time > TIME_BETWEEN_TICKS) {
    handleServerTick(time_since_last_tick);
    last_tick_time = get_program_time();
  }

  // Round-robin : satu klien, satu paket per iterasi
  client_index = (client_index + 1) % MAX_PLAYERS;
  if (clients[client_index] == -1) continue;

  // Membaca header paket : 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);
}

Satu klien diproses per iterasi loop, dan satu paket dibaca dalam satu waktu. task_yield() di awal loop membiarkan tugas idle FreeRTOS bernapas di ESP32 -- tanpanya, timer watchdog me reset chip.

Dispatch paket adalah switch monstru 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 menurut keadaan
    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:  // Paket gerakan (kasus terbesar)
    case 0x28:  // Player action (menggali/menaruh)
    // ... 40+ kasus
  }
}

Switch dikompilasi menjadi jump table statis, yang menggantikan jump table dinamis, vtable dan map. Sempurna untuk tertanam.

Kasus 0x1D-0x20 yang terbesar -- ia menangani pembaruan posisi, damage jatuh, penyeberangan perbatasan chunk, spawn mob, pembangkitan chunk, DAN lapar. Semua 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) {
  // Memperbarui 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) {
      // Bermusuhan : berjalan ke 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);
    }

    // Membangunkan chunk di sekitar mob
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

AI mob bermusuhan itu perbandingan koordinat. Secara harfiah if (px < x) x--. Zombie mengabaikan pathfinding, A* dan penghindaran rintangan : ia menyesuaikan X dan Z independen ke pemain -- ia menembus tembok kalau ada.

Damage kontaknya 3 hati/detik. p2r3 menginginkannya tinggi karena ketiadaan pathfinding membuat 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. Pukulan zombie 3 hati menjadi 0.6 hati. p2r3 memilih formula lama ini karena dihitung dalam 2 operasi -- tanpa ambang, tanpa kurva, hanya persentase linear.

Mob pasif : 8 arah dalam lookup table, ganti haluan setiap ~40 tick. Kolom data mengenkode arah berjalan dalam 2 bit bobot tinggi, dan timer ganti arah dalam 6 bit sisanya.

Mob di Bareiron -- zombie, babi, domba

Respawn mob

Mob tidak spawn dengan random tick. Mereka muncul ketika tick server bertemu perbatasan 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 medan, seed chunk yang sama. Kalau slot mob bebas, spawn deterministik.

Crafting : tanpa matriks, if/else

// crafting.c, baris 9-347 (disederhanakan)

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // Kalau flag 0x80 diangkat, buffer craft dipakai oleh peti
  if (player->flags & 0x80) { *count = 0; *item = 0; return; }

  // Menghitung slot, menemukan item pertama, memeriksa kesamaan
  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:  /* papan, batangan... */
    case 2:  /* stik, gunting, obor */
    case 3:  /* sekop, pedang, lempeng */
    case 4:  /* meja kerja, sepatu */
    case 5:  /* pickaxe, kapak, helm */
    case 7:  /* celana, komposter */
    case 8:  /* tungku, peti, pelindung dada */
    case 9:  /* blok lengkap (besi, emas, dll.) */
  }
}

Cek pertama : kalau flag 0x80 diangkat, buffer craft didaur ulang menjadi pointer peti. Craft tetap terkunci.

Lalu, ia menghitung slot terisi, mencatat item pertama, memeriksa kesamaan. Hanya dengan itu, kamu mencocokkan tungku dalam 4 cek :

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

Untuk bentuk kompleks, ia memakai indeks item pertama dan memeriksa posisi relatif. Resep berbagi fungsi pencocokan yang sama -- material menentukan hasil.

Antarmuka craft dan peti di Bareiron

Peti : hack yang sesungguhnya

Hack memori yang dibicarakan semua orang, dalam kode nyata :

// procedures.c, baris 1262-1293

if (target == B_chest) {
  // Mencari entri peti dalam tabel 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]);  // menunjuk setelah blok peti
    break;
  }
  if (storage_ptr == NULL) return;

  // Hack memori yang mengerikan!!
  // Kita menyalin POINTER ke tabel item craft pemain
  memcpy(player->craft_items, &storage_ptr, sizeof(storage_ptr));
  player->flags |= 0x80;  // mengunci craft

  // Mengirim antarmuka peti ke klien
  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 dalam kode : // Hack memori yang mengerikan!!1!

Tepat itulah. Ia mengambil alamat memori entri berikutnya dalam block_changes[], menyalinnya ke player->craft_items (yang adalah uint16_t[9], jadi 18 byte -- cukup untuk menyimpan pointer 32 bit), dan mengangkat flag supaya tidak ada yang mencoba craft sementara itu.

Pada setiap klik di inventaris peti :

// packets.c, baris 620-638

uint8_t *storage_ptr;
memcpy(&storage_ptr, player->craft_items, sizeof(storage_ptr));
// storage_ptr kini 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;

Ia mengambil pointer dari buffer craft, dan mengakses slot dengan offset. Data peti disimpan dengan takaran 3 byte per slot (2 untuk ID, 1 untuk jumlah), ditempel bersebelahan dalam tabel blok.

Data peti yang disimpan dalam tabel blok -- hack memori

Lapar : 5 baris jenius

// main.c, baris 293-305

// Pemain mengirim paket gerakan ~20/detik ketika
// bergerak, jauh lebih sedikit ketika diam. Kita menghubungkannya
// dengan aktivitas untuk mensimulasikan lapar 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;
}
}

Secara harfiah itulah. 5 baris. Setiap paket gerakan mengurangi saturasi. Ketika saturasi mencapai nol, lapar turun dan kita me reset saturasi. Sprint (flag 0x08) menggandakan drain.

Nol timer, nol memori yang dialokasikan, nol komputasi khusus. Penghitung yang berkurang pada paket yang sudah ada.

Damage jatuh

Sistem damage paling sederhana proyek :

// Ketika pemain meninggalkan tanah, kita menyimpan Y-nya
// Ketika ia menyentuh tanah lagi, kita mengurangkan
degats = dernier_y_au_sol - y_actuel;

Satu pengurangan.

Menambang dan menaruh blok

Ketika kamu mengklik blok, paket 0x28 (Player Action) mendarat di switch. Handler harus menentukan blok apa yang ada di posisi, menghapusnya, dan menaruh item ke inventaris :

// 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: {
      // Menentukan tipe blok di posisi yang diklik
      uint8_t block = getBlockAt(x, y, z);

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

      // Menambahkan ke block_changes
      addBlockChange(x, z, y, 0);  // 0 = udara

      // Memberi item ke pemain (percaya pada klien)
      addItemToPlayer(client_fd, block_to_item(block), 1);

      // Mengirim pembaruan ke klien
      sc_blockChange(client_fd, x, y, z, 0);
      sc_ackBlockChange(client_fd, x, y, z, 0);
      break;
    }
    case PLACE_BLOCK: {
      // Membaca 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 menggabung pembangkitan medan DAN perubahan pemain :

uint8_t getBlockAt (int x, uint8_t y, int z) {
  // Pertama memeriksa perubahan pemain
  uint8_t change = getBlockChange(x, y, z);
  if (change != 0xFF) return change;

  // Kalau tidak, membaca dari medan terbangkit
  return getTerrainBlock(x, y, z);
}

Prioritas pada perubahan, fallback pada medan. Nol perdebatan, nol cache, nol overhead. getTerrainBlock di balik kapnya adalah getHeightAt + lapisan stone/dirt/grass/coal.

Tungku instan

Yang paling lucu : tungku tidak ada sebagai entitas. Kalau kamu menaruh cobblestone di petak "memasak" dan coal di "bahan bakar", hasilnya muncul segera, tanpa timer maupun tick chunk : itu hanya slot inventaris yang kosong ketika kamu menaruh item yang benar.

Tungku instan -- taruh bahan, hasil segera

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 event handler
}
#endif

Seluruh server berjalan dalam tugas FreeRTOS dengan 4096 byte stack. Itu saja. Thread utama hanya menginisialisasi WiFi dan menunggu koneksi. Begitu terhubung, ia me spawn bareiron_main yang memanggil main() standar.

Semua kode khusus ESP32 dilindungi #ifdef ESP_PLATFORM. Di PC, semua itu dikompilasi menjadi kode POSIX standar.

Yang dikorbankan

Supaya semua itu muat, ada fitur vanilla yang tidak ada :

  • Nol kompresi jaringan -- zlib terlalu mahal. Server membangkitkan chunk cepat, tapi mengirimnya adalah bottleneck.
  • Nol random tick -- pohon tumbuh dengan bone meal atau tidak. Mob spawn di perbatasan chunk.
  • Nol entitas item -- blok yang ditambang langsung masuk inventaris. Animasinya murni visual.
  • Tanpa verifikasi inventaris apa pun -- percaya pada klien. 64 berlian? OK. Satu chunk ditambang dalam 1 detik? OK. Dipakai di antara orang terpercaya.
  • Nol cahaya server -- obor dikirim setelah sisanya, klien menghitung.
  • Nol fluida progresif -- keadaan akhir instan.

Hasil akhir

Ryzen 5 3600 : ~0.5 ms per chunk. ESP32-C3 1$ : ~200 ms per chunk. Bisa dimainkan.

Benchmark pembangkitan chunk -- Ryzen vs ESP32

3+ pemain : ngadat. Sebanding dengan 2b2t di jam sibuk, kata penulis.

Beberapa pemain terhubung ke server Bareiron yang sama

Filosofi

p2r3 : "Saya cuma suka gagasan bahwa chip kecil 1 dolar yang mengonsumsi 0.5 Watt ini bisa menjalankan sesuatu seadvanced Minecraft. Sains bukan soal 'kenapa', melainkan soal 'kenapa tidak'."

Setiap baris adalah tradeoff :

  • Perlin noise → interpolasi : kurang cantik, 200x lebih cepat, nol memori
  • Matriks craft → pencocokan hardcode : kode jelek, nol byte
  • zlib → tidak ada : koneksi busuk = mati, tapi bisa dimainkan
  • Validasi → percaya : nol keamanan, nol komputasi

Setiap fitur yang absen memungkinkan fitur lain ada dalam batas perangkat keras.

3 poin yang perlu diingat :

  1. Interpolasi + RNG -- 4 titik yang di seed, medan tak terbatas, nol penyimpanan, query tanpa membangkitkan ulang chunk, 200 ms pembangkitan. Ini langkah jenius yang membuat sisanya mungkin.
  2. Setiap fitur punya biaya -- Ketiadaan kompresi, random tick dan validasi adalah pilihan yang disadari : inilah yang memungkinkan muat dalam 520 KB.
  3. Hack jelek adalah yang paling cerdas -- Peti dalam tabel blok lewat memcpy, lapar lewat paket gerakan, tungku instan. Solusi bersihnya terlalu mahal.

Kalau proyek ini menarik buatmu, semuanya ada di GitHub berlisensi GPLv3. Ini C yang kotor banget, dan saya jarang begitu menikmati membaca kode sumber.

Bareiron, 1$ माइक्रोकंट्रोलर पर चलने वाला Minecraft serveur

C की 6800 पंक्तियाँ, शून्य malloc, Perlin noise bilinear interpolation से बदला, tile map में biome, और यह सब 1$ चिप पर।

परिचय

1 डॉलर के माइक्रोकंट्रोलर पर Minecraft serveur चलाना, मजाक जैसा लगता है।

मैंने प्रश्न पूछा, और उत्तर हाँ है। सचमुच।

Bareiron नामक प्रोजेक्ट है, p2r3 हस्ताक्षरित, और यह शायद हाल सालों में Minecraft दुनिया में देखे सबसे आकर्षक प्रोजेक्टों में से एक है। हम 300 kilobyte में समाने वाले binary की बात करते हैं, C की 6800 पंक्तियाँ, शून्य बाहरी निर्भरता, कोई malloc नहीं, कोई threading नहीं, और यह 1 डॉलर वाली ESP32 पर चलता है।

ESP32-C3, माइक्रोकंट्रोलर जो serveur चलाता है

अनंत भूभाग पीढ़ी। Biome। गुफाएँ। Craft। खनन। Mob। भूख। संदूक। survival serveur से जो उम्मीद करते हो सब।

0.5 Watt उपभोग और 160 MHz clock वाली चिप पर।

अंदाजा देने के लिए : vanilla Minecraft serveur को कई giga RAM चाहिए। ESP32-C3, यह 520 KB SRAM है (boot बाद 400 उपलब्ध)। 20 साल पहले processor पहले ही gigahertz चलते थे, यह 160 MHz पर ठहरता है। शुद्ध शक्ति में दोनों के बीच कारक लगभग 20 000 है।

p2r3 ने C में Minecraft serveur नहीं लिखा, उसने serveur की हर ईंट फिर से आविष्कृत की ताकि वह इन बाधाओं में समाए। हम देखेंगे कैसे, स्रोत कोड खोलकर।

p2r3 की Bareiron प्रस्तुति वीडियो की थंबनेल

प्रोजेक्ट मस्तिष्क : बिना स्मृति भूभाग पीढ़ी

जब तुम embedded MC serveur बनाना चाहते हो, सबसे बड़ी समस्या भूभाग पीढ़ी है।

vanilla Minecraft में, दुनिया Perlin noise से बनती है : कई叠 परतें (octave), 6 biom पैरामीटर (तापमान, आर्द्रता, महाद्वीपीयता, अपरदन, weirdness, गहराई), और हर बार सब फिर से गणना न करने के लिए पूरा caching तंत्र।

परिणाम शानदार है। लेकिन यह गणना में महँगा है, और बने chunk संग्रहित करने के लिए RAM लेता है।

Bareiron दृष्टिकोण मौलिक अलग है। शोर叠 करने के बजाय, वह निर्णायक RNG से बने 4 बिंदुओं पर bilinear interpolation इस्तेमाल करता है।

तुम जानते हो जब तुम छोटी pixel छवि बढ़ाते हो और किनारे धुंधले होते हैं ? बिल्कुल यही है।

// 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 निर्देशांक
  int _z = floor(z / CHUNK_SIZE);
  int rx = x % CHUNK_SIZE;          // chunk अंदर offset
  int rz = z % CHUNK_SIZE;
  uint32_t hash = getChunkHash(_x, _z);
  uint8_t biome = getChunkBiome(_x, _z);
  // hash + biome से seed 4 कोनों के बीच interpolation
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

मानक bilinear interpolation : 4 कोने, स्थिति अनुसार भार, output में एक ही uint8_t। CHUNK_SIZE 8 है, इसलिए यह पूर्णांक गुणा में होता है, float नहीं।

p2r3 इसे वीडियो में चरण दर चरण दिखाता है : पहले chunk के 4 कोने, हर एक RNG से seed।

chunk के 4 कोने, हर एक निर्णायक RNG से seed

फिर इन 4 बिंदुओं के बीच interpolation सतत सतह बनाती है।

4 कोनों के बीच bilinear interpolation लागू करना

और सभी आसन्न chunk पर pattern दोहराकर, हमें अनंत फैलता भूभाग मिलता है।

अंतिम परिणाम : सतत अनियमित भूभाग

निर्णायक RNG

जो कुंजी यह सब संभव बनाती है, वह seeding है। हर chunk के 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);  // वैश्विक seed के 32 बिट
  return splitmix64(*((uint64_t *)buf));  // hash
}

वह X के 16 बिट, Z के 16 बिट, और seed के 32 बिट को 8 byte buffer में pack करता है, और सब splitmix64 में डालता है। परिणाम : हर स्थिति के लिए अनोखा निर्णायक मान, दुनिया seed आधारित।

तंत्र की शक्ति पकड़ रहे हो ? serveur को भूभाग संग्रहित करने की जरूरत नहीं है। वह खिलाड़ी नया क्षेत्र आने पर तुरंत फिर से गणना करता है, और यह हर बार बिल्कुल वही परिणाम देता है।

इस्तेमाल splitmix64 64 बिट hash के लिए डिजाइन अति तेज 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/shift, गुणा, xor/shift, गुणा, xor/shift। सब सीधे ऑपरेशन में समाता है, बिना तालिका या लूप। वह 8 byte buffer (X + Z + seed) लेता है, उसे 64 बिट पूर्णांक मानता है, और 32 बिट hash लौटाता है। यह निर्णायक, तेज है, और 5 पंक्तियों में समाता है।

यह Perlin noise क्यों नहीं है

p2r3 खुद वीडियो में कहता है : "जितने ज्यादा बेतरतीब संख्या अंक जोड़ते हो, भूभाग उतना नियमित होता है, जैसे ज्यादा सिक्का उछाल तुम्हें 50/50 के करीब लाते हैं"। व्यवहार में, यह hash के बिट संख्या है जिसे वह जोड़ता है :

// worldgen.c, पंक्तियाँ 51-115

// plains biome के लिए : 4 संयुक्त कारक → नियमित भूभाग
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// snowy plains के लिए : 2 कारक → ज्यादा ऊबड़ खाबड़
h = (hash % 5) + ((hash >> 4) % 5);

हर biome चुनता है कि वह कितने बिट निष्कर्षण जोड़ता है। जितने ज्यादा, वितरण उतना स्थिर होता है, जैसे ज्यादा सिक्का उछाल जो 50/50 के करीब आते हैं। जितने कम, स्थानीय भिन्नताएँ उतनी मजबूत।

अनियमित भूभाग , कम कारक, मजबूत भिन्नताएँ

सिर्फ 2 कारकों से, snowy plains पहाड़ी, लगभग पर्वतीय भूभाग बनाता है। चोटियाँ और गड्ढे बार बार हैं।

नियमित भूभाग , कई कारक, चिकनी सतह

4 कारकों से, मैदान सपाट और पूर्वानुमेय रहते हैं। वितरण स्थिर होता है।

एक chunk ESP32 पर 200 ms में बनता है, Perlin noise से उसी hardware पर अमापनीय समय के मुकाबले इतना महँगा है।

मारने वाला विवरण : पूरा chunk बनाए बिना ब्लॉक पूछना

तुम खेलते हो, तुम ब्लॉक खनन करते हो। serveur को जानना होगा कि तुम्हें कौन सा item देना है। भोलेपन से, इसके लिए पूरा chunk बनाना होगा।

bilinear interpolation से, तुम सीधे निर्देशांक से समतल का कोई भी बिंदु पूछते हो। chunk कोने खिलाड़ी स्थिति से मिलते हैं, interpolation तुम्हें किसी भी offset पर ऊँचाई देती है। मुट्ठी गणित ऑपरेशन, कोई chunk पीढ़ी नहीं।

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[] से biome निर्भर है, मैदानों के लिए grass, रेगिस्तान के लिए sand। if का साधारण झरना सही परत में गिरता है, जो लूप और switch बदलता है।

गुफाएँ, सबसे आलसी mirror

altitude_grotte = CAVE_BASE_DEPTH - (hauteur_surface - y);

वह सतह ऊँचाई को जमीन नीचे mirror करता है। यह बड़ी deepslate गुहाओं जैसा लगता है। शून्य compute, एक पंक्ति।

सतह भूभाग mirror से बनी गुफाएँ

गुफाएँ बनाने के लिए भूभाग mirror schema

अयस्क, XOR संस्करण

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

निर्देशांक का XOR प्रति कॉलम उम्मीदवार गारंटी देता है। प्रकार सिर्फ ऊँचाई निर्भर है। हीरे गुफाओं के सबसे निचले बिंदु नीचे छिपे हैं ताकि खोदना उपयोगी रहे।

tile map में biome

हर biome ग्रिड में गोल द्वीप है, उसका प्रकार seed से गणना pattern से तय होता है। ग्रिडबद्ध, पूर्वानुमेय, और मुफ्त।

tile map में biome नक्शा , हर द्वीप अलग biome है

हर biome के अपने पैरामीटर तालिकाओं में एन्कोड हैं :

// 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 पर clamp

Forest : ऊँचाई 50, plains जैसे 4 कारक लेकिन ऊँचा आधार → जंगली पहाड़ियाँ।

Taiga : ऊँचाई 46, 3 कारक → मध्यम भिन्नताएँ, ठंडा भूभाग।

Snowy plains : ऊँचाई 40, सिर्फ 2 कारक → सबसे ऊबड़ खाबड़।

h = (hash % 5) + ((hash >> 4) % 5);
// परिणाम : अधिकतम ±14 ब्लॉक भिन्नता

हर biome 5 प्रविष्टियों की 3 तालिकाओं में एन्कोड है : आधार ऊँचाई, सतह ब्लॉक, कारक संख्या। जब getHeightAtFromHash biome पाता है, तो वह भूभाग समायोजित करने के लिए इन तालिकाओं से पूछता है। vanilla Minecraft की पूरी biome तंत्र बदलने के लिए 15 byte डेटा।

biome detector chunk से कौन सा biome मेल खाता है यह तय करने के लिए seed इस्तेमाल करता है :

// 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 प्रविष्टियों का pattern, chunk निर्देशांक से seed सूचकांक। यह दोहराव लेकिन दृष्टिगत सुसंगत ग्रिड देता है। vanilla Minecraft की पूरी biom पैरामीटर तंत्र बदलने के लिए 4 पंक्ति कोड।

getHeightAtFromHash : भूभाग assembler

पीढ़ी हृदय फंक्शन biome से seed 4 कोने जोड़ता है :

// worldgen.c (सरलीकृत)

static uint8_t getHeightAtFromHash (int rx, int rz, short cx, short cz,
                                    uint32_t h, uint8_t biome) {
  // hash से निकाले 4 कोने, प्रति कोना अलग seed
  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 बाधा : रेगिस्तान कभी पानी नीचे नहीं
  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 कोनों से Interpolation
  return interpolate(h1, h2, h3, h4, rx, rz);
}

हर biome की biome_base है जो संदर्भ ऊँचाई बदलती है, और 4 कोने अलग बदलावों से hash से निकाले जाते हैं। रेगिस्तान min को समुद्र स्तर ऊपर बाध्य करता है, अतिरिक्त biom गणना के बिना पानी रोकने वाली बाधा पंक्ति।

पेड़ और cactus : संभाव्य placement

सतह पीढ़ी कहाँ लगाना है यह तय करने के लिए वही chunk hash इस्तेमाल करती है :

// worldgen.c (सरलीकृत)

static void genFoliage (uint8_t *chunk_data, short cx, short cz,
                        uint32_t hash, uint8_t biome) {
  if (biome == BIOME_DESERT) {
    // Cactus : प्रति chunk एक उम्मीदवार, hash स्थिति तय करता है
    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 {
    // पेड़ : hash तय करता है कि लगाना है और कहाँ
    int tree_count = (hash & 3);  // प्रति chunk 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);
    }
  }
}

हरे biome के लिए प्रति chunk 0-3 पेड़, रेगिस्तान के लिए अधिकतम 1 cactus। chunk hash entropy का एकमात्र स्रोत है, chunk में स्थिति के लिए & 7, counter के लिए & 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);

  // chunk के हर कॉलम के लिए (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;
      }
    }
  }

  // सतह तत्व जोड़ना (पेड़, cactus)
  genFoliage(chunk, cx, cz, hash, biome);
}

बस इतना। 3 नेस्ट लूप : हर कॉलम के लिए, ऊँचाई खोजना, ब्लॉक भरना, अगले पर जाना। output uint8_t[16384] (8 × 8 × 256) है जो पूरा chunk दर्शाता है। chunk बनता है फिर सीधे client को भेजा जाता है, बिना caching, lazy loading या संपीड़न से गुजरे।

भंडारण : हर जगह स्थिर array

Bareiron स्मृति आर्किटेक्चर पूरी शान में embedded C है : सिर्फ निश्चित आकार के वैश्विक array, न malloc, न hash map, न linked list।

ब्लॉक बदलाव

// globals.h, पंक्तियाँ 191-196

typedef struct {
  short x;      // 2 byte , क्षैतिज 32 000 ब्लॉक तक सीमित
  short z;      // 2 byte
  uint8_t y;    // 1 byte , ऊर्ध्वाधर 256 ब्लॉक तक सीमित
  uint8_t block; // 1 byte , 256 ब्लॉक प्रकार तक सीमित
} BlockChange;

20 000 प्रविष्टियाँ, यानी लगभग 25 000 बदलाव, पूरी तरह खोदा डेढ़ chunk बराबर। 0xFF पर block फ़ील्ड मुक्त प्रविष्टि चिह्नित करता है। खोज रैखिक scan है :

ब्लॉक array स्मृति layout , प्रति प्रविष्टि 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;  // chest डेटा छोड़ो
    #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 ++;
}

counter, सूचकांक, लेखन। array बस ढेर करता है : कोई छँटाई नहीं, कोई compaction नहीं, कोई स्मृति प्रबंधन नहीं। जब array भरा है, तो नए बदलाव नजरअंदाज किए जाते हैं, भूभाग अपनी बनी स्थिति पर लौटता है।

256 ब्लॉक सीमा पर लेखक टिप्पणी : "मैं जल्द हल्के patina मोम ताँबा सीढ़ियाँ लागू नहीं गिन रहा।"

mob : प्रति pipe head 8 byte

// globals.h, पंक्तियाँ 240-251 (padding खत्म करने के लिए pragma pack(push, 1))

typedef struct {
  uint8_t type;   // 25=chicken, 28=cow, 95=pig, 106=sheep, 145=zombie
  short x;
  uint8_t y;      // अगर health=0, तो Y हटाने से पहले timer बनता है
  short z;
  uint8_t data;   // बिट 0-4: health, बिट 5: sheep sheared, बिट 6-7: panic timer
} MobData;

8 byte। अधिकतम 16 स्थान। संरेखण और padding खाई में जाते हैं। data byte घर bitfield है : 5 जीवन बिट, 1 कतरन बिट, 2 दहशत timer बिट। और जब mob मरता है, Y फ़ील्ड हटाने से पहले timer बनता है। बिट स्तर पर स्मृति पुनः उपयोग।

खिलाड़ी : कसा pack

खिलाड़ी डेटा भी #pragma pack(push, 1) इस्तेमाल करता है, short + uint8_t में निर्देशांक, uint16_t + uint8_t के स्थिर array में inventory, और flags फ़ील्ड जो एक साथ हमला cooldown, spawn स्थिति, sneak, sprint, eat, load, movement cooldown, और craft lock एन्कोड करता है। सब अलग बिटों में।

मुख्य लूप : while(true) और गैर अवरुद्ध

पूरा serveur एक लूप, एक thread, शून्य event library चलता है।

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

  // अगर समय बीता तो serveur tick
  if (get_program_time() - last_tick_time > TIME_BETWEEN_TICKS) {
    handleServerTick(time_since_last_tick);
    last_tick_time = get_program_time();
  }

  // Round-robin : प्रति दोहराव एक client, एक packet
  client_index = (client_index + 1) % MAX_PLAYERS;
  if (clients[client_index] == -1) continue;

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

हर लूप दोहराव में एक ही client प्रोसेस होता है, और एक बार में एक ही packet पढ़ा जाता है। लूप शुरुआत में task_yield() ESP32 पर FreeRTOS idle task साँस लेने देता है, इसके बिना, watchdog timer चिप reset करता है।

packet dispatch 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 packet (सबसे बड़ा मामला)
    case 0x28:  // Player action (dig/place)
    // ... 40+ मामले
  }
}

switch स्थिर jump table में संकलित होता है, जो गतिशील jump table, vtable और map बदलता है। embedded के लिए सही।

0x1D-0x20 मामला सबसे बड़ा है, वह स्थिति अद्यतन, गिरने नुकसान, chunk सीमा पार, mob spawn, chunk पीढ़ी, और भूख संभालता है। सब एक ही बड़े fall-through में।

Bareiron serveur कोड , C की 6800 पंक्तियाँ

serveur tick और mob AI

handleServerTick फंक्शन हर 50 ms (20 TPS) बुलाया जाता है। वह दुनिया संभालता है जबकि मुख्य लूप खिलाड़ियों का ध्यान रखता है :

// main.c (सरलीकृत)

void handleServerTick (uint32_t delta) {
  // हर mob अद्यतन करना
  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);
    }

    // mob आसपास chunk जगाना
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

शत्रुतापूर्ण mob AI निर्देशांक तुलना है। सचमुच if (px < x) x--। zombie pathfinding, A* और बाधा परिहार नजरअंदाज करता है : वह X और Z स्वतंत्र खिलाड़ी की ओर समायोजित करता है, अगर दीवारें हैं तो उन्हें पार करता है।

संपर्क नुकसान 3 heart/sec है। p2r3 ने इसे ऊँचा चाहा क्योंकि pathfinding अनुपस्थिति zombie को kite करना आसान बनाती है।

armor सूत्र combat update से पहले वाला है, सबसे सरल संभव :

// main.c (सरलीकृत)

static uint8_t applyArmor (uint8_t damage, uint16_t armor_value) {
  // Pre-1.9 सूत्र : रैखिक न्यूनीकरण
  // हर armor अंक = 4% न्यूनीकरण, अधिकतम 80%
  uint8_t reduction = (armor_value * 4);
  if (reduction > 80) reduction = 80;
  return damage * (100 - reduction) / 100;
}

Full diamond = 80% न्यूनीकरण। zombie का 3 heart प्रहार 0.6 heart बनता है। p2r3 ने यह पुराना सूत्र चुना क्योंकि वह 2 ऑपरेशन में गणना होता है, कोई सीमा नहीं, कोई वक्र नहीं, सिर्फ रैखिक प्रतिशत।

निष्क्रिय mob : lookup table में 8 दिशाएँ, लगभग हर 40 tick पाठ्यक्रम बदलाव। data फ़ील्ड उच्च वजन 2 बिट में चल रही दिशा एन्कोड करता है, और बचे 6 बिट में दिशा बदलाव timer।

Bareiron में Mob , zombie, सूअर, भेड़

mob respawn

mob random tick से spawn नहीं करते। वे प्रकट होते हैं जब serveur 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, वही chunk seed। अगर mob स्थान मुक्त है, तो spawn निर्णायक है।

craft : कोई मैट्रिक्स नहीं, if/else

// crafting.c, पंक्तियाँ 9-347 (सरलीकृत)

void getCraftingOutput (PlayerData *player, uint8_t *count, uint16_t *item) {
  // अगर 0x80 flag उठा है, तो craft buffer संदूक से इस्तेमाल होता है
  if (player->flags & 0x80) { *count = 0; *item = 0; return; }

  // slot गिनना, पहला item खोजना, पहचान जाँचना
  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:  /* फावड़े, तलवारें, slab */
    case 4:  /* craft तालिका, boot */
    case 5:  /* कुदाल, कुल्हाड़ी, helmet */
    case 7:  /* टाँगें, composter */
    case 8:  /* भट्ठी, संदूक, chestplate */
    case 9:  /* पूर्ण ब्लॉक (लोहा, सोना, आदि) */
  }
}

पहली जाँच : अगर 0x80 flag उठा है, तो craft buffer संदूक pointer के रूप में recycle होता है। craft locked रहता है।

फिर, वह भरे slot गिनता है, पहला item नोट करता है, पहचान जाँचता है। बस इससे, तुम 4 जाँच में भट्ठी match करते हो :

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

जटिल आकृतियों के लिए, वह पहले item सूचकांक इस्तेमाल करता है और सापेक्ष स्थिति जाँचता है। नुस्खे वही matching फंक्शन साझा करते हैं, सामग्री परिणाम तय करती है।

Bareiron में craft और संदूक इंटरफेस

संदूक : असली hack

स्मृति hack जिसकी सब बात करते हैं, असली कोड में :

// procedures.c, पंक्तियाँ 1262-1293

if (target == B_chest) {
  // ब्लॉक 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]);  // संदूक ब्लॉक बाद इंगित करता है
    break;
  }
  if (storage_ptr == NULL) return;

  // Terrible memory hack!!
  // हम खिलाड़ी craft item array में POINTER कॉपी करते हैं
  memcpy(player->craft_items, &storage_ptr, sizeof(storage_ptr));
  player->flags |= 0x80;  // craft lock करता है

  // 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] है, इसलिए 32 बिट pointer संग्रहित करने को काफी 18 byte) में कॉपी करता है, और flag उठाता है ताकि इस बीच कोई craft करने की कोशिश न करे।

संदूक inventory में हर क्लिक पर :

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

वह craft buffer से pointer लेता है, और offset से slot पहुँचता है। संदूक डेटा प्रति slot 3 byte दर (ID के लिए 2, मात्रा के लिए 1) संग्रहित होता है, ब्लॉक array में एक के बाद एक चिपका।

ब्लॉक array में संग्रहित संदूक डेटा , स्मृति hack

भूख : प्रतिभा की 5 पंक्तियाँ

// main.c, पंक्तियाँ 293-305

// खिलाड़ी हिलने पर ~20/sec गति packet भेजते हैं
// हिले बिना रहने पर बहुत कम। हम इसे जोड़ते हैं
// मुफ्त में भूख अनुकरण करने के लिए गतिविधि के साथ।
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 reset करते हैं। sprint (flag 0x08) drain दोगुना करता है।

शून्य timer, शून्य आवंटित स्मृति, शून्य समर्पित compute। counter जो पहले से मौजूद packet पर घटता है।

गिरने नुकसान

प्रोजेक्ट का सबसे सरल नुकसान तंत्र :

// जब खिलाड़ी जमीन छोड़ता है, तो हम उसका Y संग्रहित करते हैं
// जब वह जमीन छूता है, तो हम घटाते हैं
degats = dernier_y_au_sol - y_actuel;

एक घटाव।

ब्लॉक खनन और रखना

जब तुम ब्लॉक क्लिक करते हो, packet 0x28 (Player Action) switch में उतरता है। handler को स्थिति पर कौन सा ब्लॉक है यह तय करना होगा, उसे हटाना होगा, और item inventory में डालना होगा :

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

      // खिलाड़ी को item देना (client पर भरोसा)
      addItemToPlayer(client_fd, block_to_item(block), 1);

      // client को अद्यतन भेजना
      sc_blockChange(client_fd, x, y, z, 0);
      sc_ackBlockChange(client_fd, x, y, z, 0);
      break;
    }
    case PLACE_BLOCK: {
      // खिलाड़ी 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 भूभाग पीढ़ी और खिलाड़ी बदलाव जोड़ता है :

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। शून्य बहस, शून्य cache, शून्य overhead। हुड नीचे getTerrainBlock, यह getHeightAt + stone/dirt/grass/coal परतें हैं।

तुरंत भट्ठी

सबसे मजेदार : भट्ठी इकाई के रूप में मौजूद नहीं है। अगर तुम "पकाना" खाने में cobblestone और "fuel" में coal रखते हो, तो परिणाम तुरंत प्रकट होता है, बिना timer या chunk ticking : यह बस inventory slot है जो सही item रखने पर खाली होता है।

तुरंत भट्ठी , सामग्री रखो, तुरंत परिणाम

ESP32 लूप : 4 KB stack में MC serveur

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

पूरा serveur 4096 byte stack वाली FreeRTOS कार्य में चलता है। बस इतना। मुख्य thread सिर्फ WiFi आरंभ करता है और कनेक्शन प्रतीक्षा करता है। एक बार जुड़ने पर, वह bareiron_main spawn करता है जो मानक main() बुलाता है।

सारा ESP32 विशिष्ट कोड #ifdef ESP_PLATFORM से सुरक्षित है। PC पर, यह सब मानक POSIX कोड में संकलित होता है।

क्या त्यागा गया

यह सब समाने के लिए, vanilla सुविधाएँ हैं जो मौजूद नहीं हैं :

  • शून्य नेटवर्क संपीड़न , zlib बहुत महँगा। serveur chunk तेज बनाता है, लेकिन उन्हें भेजना bottleneck है।
  • शून्य random tick , पेड़ bone meal से उगते हैं या नहीं। mob chunk सीमाओं पर spawn करते हैं।
  • शून्य item इकाई , खनन ब्लॉक सीधे inventory जाते हैं। एनीमेशन विशुद्ध दृश्य है।
  • कोई inventory जाँच नहीं , client पर भरोसा। 64 हीरे ? ठीक। 1 sec में खनन chunk ? ठीक। भरोसे लोगों के बीच इस्तेमाल करना।
  • शून्य serveur प्रकाश , मशालें बाकी सब बाद भेजी जाती हैं, client गणना करता है।
  • शून्य प्रगतिशील तरल , तुरंत अंतिम स्थिति।

अंतिम परिणाम

Ryzen 5 3600 : प्रति chunk ~0.5 ms। 1$ ESP32-C3 : प्रति chunk ~200 ms। खेलने योग्य।

chunk पीढ़ी benchmark , Ryzen बनाम ESP32

3+ खिलाड़ी : यह रेंगता है। लेखक अनुसार, peak समय 2b2t तुलनीय।

उसी Bareiron serveur से जुड़े कई खिलाड़ी

दर्शन

p2r3 : "मुझे बस विचार पसंद है कि 0.5 Watt उपभोग करने वाली 1 ball की यह छोटी चिप Minecraft जितनी उन्नत चीज चला सकती है। Science isn't about 'why', it's about 'why not'."

हर पंक्ति tradeoff है :

  • Perlin noise → interpolation : कम सुंदर, 200x तेज, शून्य स्मृति
  • Craft मैट्रिक्स → hardcoded matching : गंदा कोड, शून्य byte
  • zlib → कुछ नहीं : सड़ा कनेक्शन = मौत, लेकिन खेलने योग्य
  • Validation → भरोसा : शून्य सुरक्षा, शून्य compute

हर अनुपस्थित सुविधा hardware सीमाओं में दूसरी को मौजूद करने देती है।

याद रखने वाले 3 बिंदु :

  1. Interpolation + RNG , 4 seed बिंदु, अनंत भूभाग, शून्य भंडारण, chunk फिर से बनाए बिना query, 200 ms पीढ़ी। यह प्रतिभा चाल है जो बाकी सब संभव बनाती है।
  2. हर सुविधा की लागत है , संपीड़न, random tick और validation अनुपस्थिति माना चुनाव है : यही 520 KB में समाने देता है।
  3. गंदे hack सबसे बुद्धिमान हैं , memcpy से ब्लॉक array में संदूक, गति packet से भूख, तुरंत भट्ठी। साफ समाधान बहुत महँगा होता।

अगर प्रोजेक्ट तुम्हारी रुचि का है, तो सब GPLv3 में GitHub पर है। यह अच्छा गंदा C है, और मैंने स्रोत कोड पढ़ने में शायद ही इतना मजा लिया हो।

Bareiron، خادم Minecraft يعمل على متحكم دقيق بـ1$

6800 سطر C وصفر malloc وضجيج Perlin مستبدل باستيفاء ثنائي الخطية

مقدمة

تشغيل خادم Minecraft على متحكم دقيق بدولار واحد يشبه مزحة.

طرحت السؤال على نفسي، والجواب نعم. حرفيا.

هناك مشروع اسمه Bareiron، بتوقيع p2r3، وربما من أكثر المشاريع سحرا التي رأيتها في عالم Minecraft السنوات الأخيرة. نتكلم عن ثنائي يتسع في 300 كيلوبايت و6800 سطر C وصفر تبعية خارجية ولا malloc ولا threading، ويعمل على ESP32 بدولار واحد.

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

توليد تضاريس لانهائي. وbiomes. وكهوف. وصنع. وتعدين. ووحوش. وجوع. وصناديق. كل ما تنتظره من خادم survival.

على شريحة تستهلك 0.5 Watt ولديها 160 MHz ساعة.

لإعطاء فكرة : يحتاج خادم Minecraft الأصلي عدة غيغا RAM. أما ESP32-C3 فهي 520 KB من SRAM (400 متاحة بعد الإقلاع). كانت المعالجات قبل 20 عاما تعمل بالغيغاهرتز فعلا، وهذه تسقف عند 160 MHz. العامل بين الاثنين في القوة الخالصة حوالي 20 000.

لم يكتب p2r3 خادم Minecraft بلغة C، بل أعاد اختراع كل لبنة من الخادم لتتسع في هذه القيود. سننظر كيف، بفتح الكود المصدري.

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

دماغ المشروع : توليد تضاريس دون ذاكرة

أكبر مشكلة عندما تريد صنع خادم MC مضمن هي توليد التضاريس.

في Minecraft الأصلي، يُولَّد العالم مع ضجيج Perlin : عدة طبقات متراكبة (octaves) و6 معاملات biomية (حرارة ورطوبة وقارية وتعرية وغرابة وعمق)، وكل نظام تخزين مؤقت لعدم إعادة حساب كل شيء في كل مرة.

النتيجة رائعة. لكنها مكلفة حسابيا، وتأخذ RAM لتخزين chunks المولدة.

مقاربة 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);  // إحداثيات chunk
  int _z = floor(z / CHUNK_SIZE);
  int rx = x % CHUNK_SIZE;          // إزاحة داخل chunk
  int rz = z % CHUNK_SIZE;
  uint32_t hash = getChunkHash(_x, _z);
  uint8_t biome = getChunkBiome(_x, _z);
  // استيفاء بين 4 زوايا مُبذَرة بالـhash + biome
  return getHeightAtFromHash(rx, rz, _x, _z, hash, biome);
}

الاستيفاء ثنائي الخطية القياسي : 4 زوايا وأوزان حسب الموضع، ومخرج uint8_t واحد. CHUNK_SIZE هو 8، إذن يتم بضرب أعداد صحيحة، دون float.

يُظهر p2r3 ذلك خطوة بخطوة في الفيديو : أولا زوايا chunk الأربع، لكل واحدة ارتفاع مُبذَر بالـRNG.

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

ثم ينشئ الاستيفاء بين هذه النقاط الأربع سطحا متصلا.

Application de la bilinear interpolation entre les 4 coins

وبتكرير النمط على كل chunks المتجاورة، نحصل على تضاريس تمتد للانهاية.

Résultat final : terrain irrégulier continu

RNG الحتمي

المفتاح الذي يجعل كل ذلك ممكنا هو البذر. لكل chunk 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));  // hash
}

يحزم 16 بت من X و16 بت من Z و32 بت من البذرة في عازل 8 بايتات، ويمرر الكل في splitmix64. النتيجة : قيمة حتمية فريدة لكل موضع، قائمة على بذرة العالم.

تستوعب قوة الآلية ؟ لا يحتاج الخادم تخزين التضاريس. يعيد الحساب لحظيا عندما يصل اللاعب لمنطقة جديدة، ويعطي نفس النتيجة بالضبط في كل مرة.

splitmix64 المستخدم prng فائق السرعة مصمم لـhash بحجم 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/shift وضرب وxor/shift وضرب وxor/shift. الكل في عمليات مباشرة، دون جدول ولا حلقة. يأخذ عازل 8 بايتات (X + Z + بذرة)، ويعامله كعدد صحيح 64 بت، ويعيد 32 بت hash. إنه حتمي وسريع، ويتسع في 5 أسطر.

لماذا ليس ضجيج Perlin

يقوله p2r3 بنفسه في الفيديو : "كلما أضفت أرقاما من العدد العشوائي، صار التضاريس أكثر انتظاما، مثل رميات قطع أكثر تقربك من 50/50". عمليا، إنه عدد بتات hash التي يدمجها :

// worldgen.c، أسطر 51-115

// لـbiome السهول : 4 عوامل مدمجة ← تضاريس منتظمة
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// لـbiome السهول الثلجية : عاملان ← أكثر وعورة
h = (hash % 5) + ((hash >> 4) % 5);

يختار كل biome كم استخراج بتات يدمج. وكلما زاد العدد، استقر التوزيع أكثر، مثل رميات قطع أكثر تقترب من 50/50. وكلما قل، كانت التغيرات المحلية أقوى.

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

مع عاملين فقط، تنتج السهول الثلجية تضاريس متموجة، شبه جبلية. القمم والحفر متكررة.

Terrain régulier -- facteurs multiples, surface lisse

مع 4 عوامل، تبقى السهول مسطحة ومتوقعة. يستقر التوزيع.

يُتولَّد chunk في 200 ms على ESP32، مقابل زمن غير قابل للقياس على نفس العتاد مع ضجيج Perlin لشدة kostه.

التفصيل القاتل : استعلام كتلة دون توليد chunk كله

تلعب، وتعدّن كتلة. يجب أن يعرف الخادم أي عنصر يعطيك. بسذاجة، يلزم توليد chunk كله لذلك.

مع الاستيفاء ثنائي الخطية، تستعلم أي نقطة من المستوى مباشرة من الإحداثيات. تُحصَل زوايا chunk من موضع اللاعب، ويعطيك الاستيفاء الارتفاع عند أي إزاحة. حفنة عمليات رياضية، دون توليد chunk.

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 عبر biome_top[]، عشب للسهول ورمل للصحراء. سلسلة if بسيطة تقع في الطبقة الصحيحة، مما يستبدل حلقة وswitch.

الكهوف، المرآة الأكثر كسلا

altitude_grotte = CAVE_BASE_DEPTH - (hauteur_surface - y);

يعكس ارتفاع السطح تحت الأرض. يشبه التجاويف الكبيرة في deepslate. صفر حساب وسطر واحد.

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 الإحداثيات مرشحا لكل عمود. يعتمد النوع فقط على الارتفاع. الماس مخبأ تحت أدنى نقطة في الكهوف لكي يبقى الحفر مفيدا.

biomes في خريطة tiles

كل biome جزيرة دائرية في شبكة، ونوعه محدد بنمط محسوب من البذرة. مُشبَّك ومتوقع ومجاني.

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

لكل biome مجموعة معاملات خاصة مرمزة في جداول :

// 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,   // استخراجان ← وعر جدا
};

سهول : ارتفاع 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 وعاملان فقط ← الأكثر وعورة.

h = (hash % 5) + ((hash >> 4) % 5);
// النتيجة : تغير ±14 كتلة حدا

يُرمَّز كل biome في 3 جداول من 5 مداخل : ارتفاع أساس وكتلة سطح وعدد عوامل. وعندما تستلم getHeightAtFromHash biome، تستشير هذه الجداول لضبط التضاريس. 15 بايت بيانات لاستبدال نظام biomes كله في Minecraft الأصلي.

يستخدم كاشف biome البذرة لتحديد أي biome يقابل كل chunk :

// 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 مدخلا وفهرس مُبذَر بإحداثيي chunk. يعطي شبكة متكررة لكن متسقة بصريا. 4 أسطر كود لاستبدال نظام المعاملات biomية كله في Minecraft الأصلي.

getHeightAtFromHash : مجمّع التضاريس

الدالة في قلب التوليد تجمع الزوايا الأربع المُبذَرة حسب biome :

// worldgen.c (مبسطة)

static uint8_t getHeightAtFromHash (int rx, int rz, short cx, short cz,
                                    uint32_t h, uint8_t biome) {
  // 4 زوايا مستخرجة من hash، بذرة مختلفة لكل زاوية
  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 : الصحراء أبدا تحت الماء
  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);
  }

  // استيفاء من الزوايا الأربع
  return interpolate(h1, h2, h3, h4, rx, rz);
}

لكل biome biome_base تزيح ارتفاع المرجع، والزوايا الأربع مستخرجة من hash بإزاحات مختلفة. تجبر الصحراء الدنيا فوق مستوى البحر، سطر قيد يتجنب الماء دون حساب biomي إضافي.

أشجار وصبار : وضع احتمالي

يستخدم توليد السطح نفس hash الـchunk ليقرر أين يزرع :

// worldgen.c (مبسطة)

static void genFoliage (uint8_t *chunk_data, short cx, short cz,
                        uint32_t hash, uint8_t biome) {
  if (biome == BIOME_DESERT) {
    // صبار : مرشح واحد لكل chunk، يحدد hash الموضع
    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 {
    // أشجار : يحدد hash إن وضعنا وأين
    int tree_count = (hash & 3);  // 0-3 أشجار لكل 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 الخضراء، وصبار واحد حدا للصحراء. hash الـchunk هو مصدر العشوائية الوحيد، & 7 للموضع في chunk و& 3 للعداد. كل شيء حتمي، ولا شيء مخزّن.

generateChunk : تجميع الكل

الدالة التي تجمع كل شيء لإنتاج chunk كامل من 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);

  // لكل عمود من chunk (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) يمثل chunk الكامل. يُولَّد chunk ويُرسَل مباشرة للعميل، دون المرور بتخزين مؤقت أو تحميل كسول أو ضغط.

التخزين : مصفوفات ثابتة في كل مكان

بنية ذاكرة Bareiron هي C مضمن بكل مجدها : فقط جداول شاملة بثابت الحجم، لا malloc ولا hash maps ولا قوائم مربوطة.

تغيرات الكتل

// globals.h، أسطر 191-196

typedef struct {
  short x;      // بايتان، حد 32 000 كتلة أفقيا
  short z;      // بايتان
  uint8_t y;    // بايت، حد 256 كتلة عموديا
  uint8_t block; // بايت، حد 256 نوع كتلة
} BlockChange;

20 000 مدخل، أي حوالي 25 000 تغير، ما يعادل chunk ونصف مُفرَّغين تماما. يسم الحقل 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;
}

إضافة تغير مباشرة كالبحث :

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 بتات حياة وبت جز وبتان مؤقت ذعر. وعندما يموت وحش، يصبح حقل 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;
  }

  // Tick الخادم إذا حان الوقت
  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;

  // قراءة ترويسة الحزمة : طول + معرف
  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.

توزيع الحزم هو 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
    case 0x03:  // إقرار Login/configuration
    case 0x08:  // دردشة
    case 0x0B:  // حالة Client (إعادة ظهور)
    case 0x11:  // نقر حاوية (يدير الصناديق)
    case 0x19:  // تفاعل كيان
    case 0x1D..0x20:  // حزم حركة (أكبر حالة)
    case 0x28:  // فعل لاعب (حفر/وضع)
    // ... 40+ حالة
  }
}

يُجمَّع switch في جدول قفز ثابت، مما يستبدل جدول قفز ديناميكيا وvtable وmap. مثالي للمضمن.

الحالة 0x1D-0x20 هي الأكبر، فتدير تحديثات الموضع وأضرار السقوط وعبور حدود chunk وظهور الوحوش وتوليد chunks والجوع. الكل في fall-through ضخم واحد.

Le code du serveur Bareiron -- 6800 lignes de C

tick الخادم وذكاء الوحوش

تُستدعَى الدالة handleServerTick كل 50 ms (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 ++;
      // أضرار تماس على بعد كتلتين
      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 ticks
      if (mob->data >> 6 < 1) setMobDir(mob, rand() & 7);
      mob->data = (mob->data & 0x3F) | ((mob->data - 0x40) & 0xC0);
    }

    // إيقاظ chunks حول الوحش
    setChunkGenerated(mob->x / 8, mob->z / 8);
  }
}

ذكاء الوحوش العدائية هو مقارنة إحداثيات. حرفيا 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 هذه الصيغة القديمة لأنها تُحسَب في عمليتين، لا عتبات ولا منحنيات، فقط نسبة خطية.

الوحوش السلبية : 8 اتجاهات في جدول بحث، وتغيير مسار كل ~40 ticks. يرمّز الحقل data الاتجاه الجاري في بتين قويين، ومؤقت تغيير الاتجاه في البتات الستة الباقية.

Mobs dans Bareiron -- zombies, cochons, moutons

إعادة ظهور الوحوش

لا تظهر الوحوش مع 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 كالتضاريس ونفس بذرة chunk. وإذا كان موضع وحش حرا، فالظهور حتمي.

الصنع : لا مصفوفات، بل 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;

للأشكال المعقدة، يستخدم فهرس أول عنصر ويفحص الموضع النسبي. تتشارك الوصفات نفس دالة المطابقة، فالمادة تحدد النتيجة.

Interface de craft et coffre dans 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 للمعرف و1 للكمية)، ملتصقة ببعضها في جدول الكتل.

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

الجوع : 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 خاصته
// وعندما يلمس الأرض مجددا، نطرح
degats = dernier_y_au_sol - y_actuel;

عملية طرح واحدة.

تعدين الكتل ووضعها

عندما تنقر كتلة، تهبط حزمة 0x28 (فعل لاعب) في switch. يجب أن يحدد المعالج أي كتلة في الموضع، ويزيلها، ويضع العنصر في المخزون :

// main.c، الحالة 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);
}

أولوية للتغيرات، وfallback على التضاريس. صفر نقاش وصفر كاش وصفر عبء. getTerrainBlock تحت الغطاء هو getHeightAt + طبقات stone/dirt/grass/coal.

الفرن الفوري

الأطرف : لا يوجد الفرن ككيان. وإذا وضعت cobblestone في خانة "طهي" وcoal في "وقود"، تظهر النتيجة فورا، دون مؤقت ولا chunk ticking : إنها فقط خانة مخزون تُفرَغ عندما تضع العناصر الصحيحة.

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

حلقة ESP32 : خادم MC في 4 KB من stack

// 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 بايت من stack. هذا كل شيء. لا يفعل خيط main الرئيسي إلا تهيئة WiFi وانتظار اتصال. وبمجرد الاتصال، يُنشِئ bareiron_main الذي يستدعي main() القياسية.

كل كود ESP32 الخاص محمي بـ#ifdef ESP_PLATFORM. على PC، يُجمَّع كل ذلك في كود POSIX قياسي.

ما ضُحِّي به

لكي يتسع كل ذلك، هناك مزايا vanilla غير موجودة :

  • صفر ضغط شبكة، zlib مكلف أكثر من اللازم. يولّد الخادم chunks سريعا، لكن إرسالها هو عنق الزجاجة.
  • صفر ticks عشوائية، تنمو الأشجار مع bone meal أم لا. تظهر الوحوش على حدود chunk.
  • صفر كيان عنصر، تذهب الكتل المُعدَّنة مباشرة للمخزون. الحركة بصرية خالصة.
  • لا فحص مخزون، ثقة بالعميل. 64 ماسة ؟ حسنا. chunk مُعدَّن في ثانية ؟ حسنا. للاستخدام بين أناس موثوقين.
  • صفر إضاءة خادم، تُرسَل المشاعل بعد كل الباقي، فيحسب العميل.
  • صفر سوائل تدريجية، حالة نهائية فورية.

النتيجة النهائية

Ryzen 5 3600 : ~0.5 ms لكل chunk. ESP32-C3 بدولار : ~200 ms لكل chunk. قابلة للعب.

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

3+ لاعبين : تتباطأ. قابلة للمقارنة مع 2b2t في ساعات الذروة، حسب الكاتب.

Plusieurs joueurs connectés au même serveur Bareiron

الفلسفة

p2r3 : "أحب فقط فكرة أن هذه الشريحة الصغيرة بدولار التي تستهلك 0.5 Watt تستطيع تشغيل شيء متقدم كـMinecraft. العلم ليس عن 'لماذا'، بل عن 'لماذا لا'."

كل سطر مقايضة :

  • ضجيج Perlin ← استيفاء : أقل جمالا وأسرع 200x وصفر ذاكرة
  • مصفوفات صنع ← مطابقة مُرمَّزة صلبا : كود قبيح وصفر بايت
  • zlib ← لا شيء : اتصال سيئ = موت، لكن قابل للعب
  • تحقق ← ثقة : صفر أمان وصفر حساب

كل مزية غائبة تسمح لأخرى بالوجود في حدود العتاد.

النقاط الثلاث للتذكر :

  1. استيفاء + RNG، 4 نقاط مُبذَرة وتضاريس لانهائية وصفر تخزين واستعلام دون إعادة توليد chunk و200 ms توليد. إنها حركة العبقرية التي تجعل كل الباقي ممكنا.
  2. لكل مزية kost، غياب الضغط والـticks العشوائية والتحقق خيار مفترض : وهذا ما يسمح بالتسع في 520 KB.
  3. hacks القذرة هي الأذكى، صناديق في جدول الكتل عبر memcpy وجوع بحزم حركة وفرن فوري. كان الحل النظيف مكلفا أكثر من اللازم.

إذا كان المشروع يهمك، فالكله على GitHub برخصة GPLv3. إنه C قذر فعلا، ونادرا ما استمتعت هكذا بقراءة كود مصدري.

Bareiron -- máy chủ Minecraft chạy trên vi điều khiển giá 1$

6800 dòng C, không malloc, Perlin noise thay bằng

Giới thiệu

Chạy máy chủ Minecraft trên vi điều khiển giá 1 đô, nghe như trò đùa.

Tôi đã tự hỏi, và câu trả lời là có. Theo nghĩa đen.

Có một dự án tên Bareiron, của p2r3, và đó có lẽ là một trong những dự án cuốn hút nhất tôi thấy trong thế giới Minecraft mấy năm gần đây. Ta nói về binary gói trong 300 kilobyte, 6800 dòng C, không phụ thuộc ngoài, không malloc, không threading, và nó chạy trên ESP32 giá 1 đô.

ESP32-C3, vi điều khiển chạy máy chủ

Sinh địa hình vô hạn. Các biome. Hang động. Chế tạo. Đào mỏ. Mob. Đói. Rương. Mọi thứ bạn mong ở máy chủ survival.

Trên con chip tiêu thụ 0.5 Watt và xung 160 MHz.

Để cho thang đo : máy chủ Minecraft vanilla cần vài GB RAM. ESP32-C3 là 520 KB SRAM (400 còn lại sau boot). Bộ xử lý 20 năm trước đã chạy ở gigahertz -- con này trần ở 160 MHz. Hệ số giữa hai bên về sức mạnh thuần khoảng 20 000.

p2r3 không viế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 các ràng buộc này. Ta sẽ xem thế nào, bằng cách mở mã nguồn.

Ảnh thu nhỏ video giới thiệu Bareiron của p2r3

Bộ não dự án : sinh địa hình không cần bộ nhớ

Vấn đề lớn nhất khi muốn làm máy chủ MC nhúng là sinh địa hình.

Trong Minecraft vanilla, thế giới sinh bằng Perlin noise : nhiều lớp chồng (octave), 6 tham số biome (nhiệt độ, độ ẩm, lục địa, xói mòn, weirdness, độ sâu), và cả hệ cache để không phải tính lại mọi thứ mỗi lần.

Kết quả tuyệt đẹp. Nhưng tốn tính toán, và tốn RAM để lưu các chunk đã sinh.

Cách của Bareiron khác triệt để. Thay vì chồng nhiễu, nó dùng bilinear interpolation trên 4 điểm sinh bởi RNG xác định.

Bạn biết khi phóng to ảnh pixel nhỏ và các cạnh mờ đi chứ? Đúng là nó.

// worldgen.c, dòng 117-171 (rút gọn)

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 chuẩn : 4 góc, trọng số theo vị trí, một uint8_t đầu ra. CHUNK_SIZE là 8, nên việc này bằng phép nhân số nguyên, không float.

p2r3 trình bày từng bước trong video : đầu tiên 4 góc của chunk, mỗi cái với chiều cao seed bởi RNG.

4 góc của chunk, mỗi cái seed bởi RNG xác định

Rồi nội suy giữa 4 điểm này tạo bề mặt liên tục.

Áp bilinear interpolation giữa 4 góc

Và lặp mẫu trên mọi chunk kề, ta được địa hình trải vô hạn.

Kết quả cuối : địa hình gồ ghề liên tục

RNG xác định

Chìa khóa khiến tất cả khả thi 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 mà tái tạo được.

// worldgen.c, dòng 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
}

Nó đóng gói 16 bit X, 16 bit Z, và 32 bit seed, vào buffer 8 byte, và chạy tất cả qua splitmix64. Kết quả : giá trị xác định duy nhất cho mỗi vị trí, dựa trên seed của thế giới.

Bạn nắm sức mạnh của cơ chế chứ? Máy chủ không cần lưu địa hình. Nó tính lại tức thì khi người chơi tới vùng mới, và cho đúng cùng kết quả mỗi lần.

splitmix64 dùng là prng siêu nhanh thiết kế cho hash 64 bit :

// worldgen.c (rút gọn)

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/shift, nhân, xor/shift, nhân, xor/shift. Tất cả gói trong thao tác trực tiếp, không bảng không vòng lặp. Nó lấy buffer 8 byte (X + Z + seed), xử lý như số nguyên 64 bit, và trả 32 bit hash. Xác định, nhanh, và gói trong 5 dòng.

Vì sao không phải Perlin noise

p2r3 tự nói trong video : "càng thêm chữ số của số ngẫu nhiên, địa hình càng đều, như càng tung đồng xu càng tiến về 50/50". Thực tế, đó là số bit của hash anh ấy kết hợp :

// worldgen.c, dòng 51-115

// Với biome plains : 4 hệ số kết hợp → địa hình đều
h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Với snowy plains : 2 hệ số → gồ ghề hơn
h = (hash % 5) + ((hash >> 4) % 5);

Mỗi biome chọn bao nhiêu trích bit nó kết hợp. Càng nhiều, phân bố càng ổn định -- như càng tung đồng xu càng tiến về 50/50. Càng ít, biến thiên cục bộ càng mạnh.

Địa hình gồ ghề -- ít hệ số, biến thiên mạnh

Với chỉ 2 hệ số, snowy plains cho địa hình đồi, gần như núi. Đỉnh và hố thường xuyên.

Địa hình đều -- nhiều hệ số, bề mặt mịn

Với 4 hệ số, đồng bằng giữ phẳng và đoán được. Phân bố ổn định.

Chunk sinh trong 200 ms trên ESP32 -- so với thời gian không đo nổi trên cùng phần cứng với Perlin noise vì nó đắt tới vậy.

Chi tiết đắt giá : hỏi khối mà không sinh cả chunk

Bạn chơi, bạn đào khối. Máy chủ phải biết cho bạn vật phẩm gì. Ngây thơ, phải sinh cả chunk vì việc đó.

Với bilinear interpolation, bạn hỏi bất kỳ điểm nào của mặt phẳng trực tiếp từ tọa độ. Các góc của chunk lấy từ vị trí người chơi, nội suy cho chiều cao ở bất kỳ offset nào. Vài phép toán, không sinh chunk.

p2r3 : "điều tôi muốn là hàm kỳ diệu có thể bảo tôi khối nào ở tọa độ cho trước, không cần chạm bộ nhớ hay tính bản đồ nhiễu đắt đỏ". Đúng điều anh ấy đã làm.

Đây là cách chiều cao thành các khối cụ thể :

// worldgen.c (rút gọn)

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 biome qua biome_top[] -- grass cho đồng bằng, sand cho sa mạc. Thác if đơn giản rơi vào đúng lớp, thay vòng lặp và switch.

Hang động, tấm gương lười nhất

altitude_grotte = CAVE_BASE_DEPTH - (hauteur_surface - y);

Nó phản chiếu chiều cao bề mặt xuống dưới lòng đất. Trông như các hốc deepslate lớn. Không tính toán, một dòng.

Hang sinh bằng phản chiếu địa hình bề mặt

Sơ đồ phản chiếu địa hình để sinh hang

Quặng, 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 viên cho mỗi cột. Kiểu chỉ phụ thuộc độ cao. Kim cương giấu dưới điểm thấp nhất của hang để đào bới vẫn đáng.

Biome dạng tile map

Mỗi biome là đảo tròn trong lưới, kiểu của nó xác định bằng mẫu tính từ seed. Lưới, đoán được, và miễn phí.

Bản đồ biome dạng tile map -- mỗi đảo là một biome khác

Mỗi biome có bộ tham số riêng mã hóa trong các bảng :

// worldgen.c (rút gọn)

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 : chiều cao 48, 4 hệ số → địa hình rất phẳng, cỏ.

h = (hash % 3) + ((hash >> 4) % 3) + ((hash >> 8) % 3) + ((hash >> 12) % 3);
// Kết quả : biến thiên tối đa ±4 khối

Desert : chiều cao 52, 3 hệ số, 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ả : biến thiên tối đa ±6 khối, kẹp ở SEA_LEVEL+1

Forest : chiều cao 50, 4 hệ số như plains nhưng nền cao hơn → đồi rừng.

Taiga : chiều cao 46, 3 hệ số → biến thiên vừa, địa hình lạnh.

Snowy plains : chiều cao 40, chỉ 2 hệ số → gồ ghề nhất.

h = (hash % 5) + ((hash >> 4) % 5);
// Kết quả : biến thiên tối đa ±14 khối

Mỗi biome mã hóa trong 3 bảng 5 mục : chiều cao nền, khối bề mặt, số hệ số. Khi getHeightAtFromHash nhận biome, nó tra các bảng này để chỉnh địa hình. 15 byte dữ liệu thay toàn bộ hệ thống biome của Minecraft.

Bộ phát hiện biome dùng seed để xác định biome nào khớp mỗi chunk :

// worldgen.c (rút gọn)

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ẫu 16 mục, index seed bởi tọa độ chunk. Cho ra lưới lặp nhưng trông nhất quán. 4 dòng mã thay toàn bộ hệ thống tham số biome của Minecraft vanilla.

getHeightAtFromHash : bộ ráp địa hình

Hàm ở lõi sinh kết hợp 4 góc seed theo biome :

// worldgen.c (rút gọn)

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

Mỗi biome có biome_base dịch chiều cao tham chiếu, và 4 góc trích từ hash với độ lệch khác nhau. Sa mạc ép min 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 biome thêm.

Cây và xương rồng : đặt theo xác suất

Sinh bề mặt dùng cùng hash chunk để quyết trồng ở đâu :

// worldgen.c (rút gọn)

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 cây cho mỗi chunk với biome xanh, tối đa 1 xương rồng cho sa mạc. Hash của chunk là nguồn entropy duy nhất -- & 7 cho vị trí trong chunk, & 3 cho bộ đếm. Mọi thứ xác định, không gì lưu trữ.

generateChunk : ráp tất cả

Hàm gom tất cả để cho ra chunk đầy đủ 8×8×256 khối :

// worldgen.c (rút gọn)

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

Hết. 3 vòng lặp lồng : với mỗi cột, tìm chiều cao, lấp khối, sang cột tiếp. Đầu ra là uint8_t[16384] (8 × 8 × 256) đại diện chunk đầy đủ. Chunk được sinh rồi gửi thẳng cho client, không qua cache, tải lười hay nén.

Lưu trữ : mảng tĩnh khắp nơi

Kiến trúc bộ nhớ của Bareiron đúng chất C nhúng huy hoàng : chỉ bảng toàn cục kích thước cố định, không malloc, không hash map, không danh sách liên kết.

Thay đổi khối

// globals.h, dòng 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 mục, tức khoảng 25 000 thay đổi -- tương đương chunk rưỡi đào sạch. Trường block ở 0xFF đánh dấu mục trống. Tìm kiếm là quét tuyến tính :

Bố cục bộ nhớ của bảng khối -- 6 byte cho 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 thay đổi trực tiếp như tìm kiếm :

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 index, một lần ghi. Bảng chỉ chồng chất : không sắp xếp, không nén, không quản lý bộ nhớ. Khi bảng đầy, các thay đổi mới bị lờ -- địa hình về trạng thái đã sinh.

Nhận xét của tác giả về giới hạn 256 khối : "je compte pas implémenter les escaliers en cuivre légèrement patinés cirés de si tôt."

Mob : 8 byte cho mỗi đầu ống

// globals.h, dòng 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 byte. Tối đa 16 vị trí. Căn chỉnh và đệm vứt bỏ. Byte data là bitfield tự chế : 5 bit máu, 1 bit cạo lông, 2 bit timer hoảng. Và khi mob chết, trường Y thành timer trước xóa. Tái dùng bộ nhớ tới cấp bit.

Người chơi : đóng chặt

Dữ liệu người chơi cũng dùng #pragma pack(push, 1) -- tọa độ bằng short + uint8_t, túi đồ bằng bảng cố định uint16_t + uint8_t, và trường flags mã hóa cùng lú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à không chặn

Toàn bộ máy chủ chạy trên một vòng lặp, một luồng, không thư viện sự kiện.

// main.c, dòng 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);
}

Một client duy nhất xử lý cho mỗi vòng lặp, và một gói duy nhất đọc mỗi lần. task_yield() ở đầu vòng lặp để task idle FreeRTOS thở trên ESP32 -- không có nó, timer watchdog reset chip của bạn.

Điều phối gói, đó là 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 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
  }
}

Switch biên dịch thành jump table tĩnh, thay jump table động, vtable và map. Hoàn hảo cho nhúng.

Ca 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 chunk, spawn mob, sinh chunk, VÀ đói. Tất cả trong một fall-through lớn.

Mã của máy chủ Bareiron -- 6800 dòng C

Tick máy chủ và AI của mob

Hàm handleServerTick được gọi mỗi 50 ms (20 TPS). Nó quản thế giới trong khi vòng lặp chính lo người chơi :

// main.c (rút gọn)

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

AI của mob thù địch là so sánh tọa độ. Theo nghĩa đen if (px < x) x--. Zombie lờ pathfinding, A* và tránh chướng ngại : nó 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 ở 3 tim/giây. p2r3 muốn nó cao vì thiếu pathfinding khiến zombie dễ thả diều.

Công thức giáp là của trước combat update -- đơn giản nhất có thể :

// main.c (rút gọn)

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% giảm. Cú zombie 3 tim thành 0.6 tim. p2r3 chọn công thức cũ này vì nó tính bằng 2 phép toán -- không ngưỡng, không đường cong, chỉ phần trăm tuyến tính.

Mob thụ động : 8 hướng trong bảng tra, đổi hướng mỗi ~40 tick. Trường data mã hóa hướng hiện trong 2 bit cao, và timer đổi hướng trong 6 bit còn lại.

Mob trong Bareiron -- zombie, lợn, cừu

Respawn mob

Mob không spawn bằng random tick. Chúng xuất hiện khi tick máy chủ gặp biên 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 như địa hình, cùng seed chunk. Nếu vị trí mob trống, spawn xác định.

Chế tạo : không ma trận, if/else

// crafting.c, dòng 9-347 (rút gọn)

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.) */
  }
}

Kiểm tra đầu : nếu cờ 0x80 bật, buffer craft tái chế thành con trỏ rương. Chế tạo giữ khóa.

Rồi, nó đếm slot đầy, ghi nhận vật phẩm đầu, kiểm tra giống nhau. Chỉ với đó, bạn khớp lò trong 4 lần kiểm tra :

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

Với hình phức tạp, nó dùng index của vật phẩm đầu và kiểm tra vị trí tương đối. Các công thức chung cùng hàm khớp -- vật liệu quyết kết quả.

Giao diện chế tạo và rương trong Bareiron

Rương : mẹo bộ nhớ thật

Mẹo bộ nhớ ai cũng bàn, trong mã thật :

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

Và comment trong mã : // Terrible memory hack!!1!

Đúng là nó. Nó lấy địa chỉ bộ nhớ của mục tiếp theo trong block_changes[], nó chép vào player->craft_items (là uint16_t[9], nên 18 byte -- đủ chứa con trỏ 32 bit), và nó bật cờ để không ai cố chế tạo lúc đó.

Mỗi lần bấm trong túi đồ của rương :

// packets.c, dòng 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;

Nó lấy lại con trỏ từ buffer craft, và nó truy cập các slot bằng offset. Dữ liệu rương lưu với 3 byte cho mỗi slot (2 cho ID, 1 cho số lượng), dán cạnh nhau trong bảng khối.

Dữ liệu rương lưu trong bảng khối -- một mẹo bộ nhớ

Đói : 5 dòng thiên tài

// main.c, dòng 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;
}
}

Đúng nghĩa đen vậy. 5 dòng. Mỗi gói di chuyển giảm bão hòa. Khi bão hòa về không, đói giảm và ta reset bão hòa. Chạy nước rút (cờ 0x08) nhân đôi hao.

Không timer, không bộ nhớ cấp, không tính toán chuyên. Bộ đếm giảm trên các gói đã tồn tại.

Sát thương rơi

Hệ sát thương đơn giản nhất của dự án :

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

Một phép trừ.

Đào và đặt khối

Khi bạn bấm vào khối, gói 0x28 (Player Action) đáp vào switch. Handler phải xác định khối nào ở vị trí, gỡ nó, và đặt vật phẩm vào túi :

// main.c, case 0x28 (rút gọn)

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 kết hợp sinh địa hình VÀ thay đổi của người chơi :

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

Ưu tiên thay đổi, dự phòng địa hình. Không tranh cãi, không cache, không chi phí. getTerrainBlock bên dưới là getHeightAt + các lớp stone/dirt/grass/coal.

Lò nung tức thì

Buồn cười nhất : lò nung không tồn tại như thực thể. Nếu bạn đặt cobblestone vào ô "nấu" và coal vào "fuel", kết quả hiện ngay, không timer hay chunk ticking : chỉ là slot túi đồ trống khi bạn đặt đúng vật phẩm.

Lò nung tức thì -- đặt nguyên liệu, kết quả ngay

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 (/* 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

Toàn bộ máy chủ chạy trong task FreeRTOS với 4096 byte stack. Hết. Luồng main chính chỉ khởi tạo WiFi và chờ kết nối. Khi nối xong, nó sinh bareiron_main gọi main() chuẩn.

Mọi mã riêng ESP32 được bảo vệ bằng #ifdef ESP_PLATFORM. Trên PC, tất cả biên dịch thành mã POSIX chuẩn.

Điều đã hy sinh

Để tất cả vừa, có các tính năng vanilla không tồn tại :

  • Không nén mạng -- zlib quá đắt. Máy chủ sinh chunk nhanh, nhưng gửi chúng là nút thắt.
  • Không random tick -- cây mọc bằng bone meal hay không. Mob spawn ở biên chunk.
  • Không thực thể vật phẩm -- khối đào đi thẳng vào túi. Animation thuần trực quan.
  • Không kiểm tra túi đồ -- trust the client. 64 kim cương? OK. Chunk đào trong 1 giây? OK. Dùng giữa người tin nhau.
  • Không ánh sáng phía máy chủ -- đuốc gửi sau mọi thứ còn lại, client tính.
  • Không chất lỏng lũy tiến -- trạng thái cuối tức thì.

Kết quả cuối

Ryzen 5 3600 : ~0.5 ms cho mỗi chunk. ESP32-C3 giá 1$ : ~200 ms cho mỗi chunk. Chơi được.

Benchmark sinh chunk -- Ryzen đấu ESP32

3+ người chơi : nó ì. Ngang 2b2t giờ cao điểm, theo lời tác giả.

Nhiều người chơi nối vào cùng máy chủ Bareiron

Triết lý

p2r3 : "Tôi chỉ thích ý tưởng con chip tí hon giá 1 xu tiêu thụ 0.5 Watt này có thể chạy thứ tân tiến như Minecraft. Science isn't about 'why', it's about 'why not'."

Mỗi dòng là đánh đổi :

  • Perlin noise → nội suy : kém đẹp, nhanh gấp 200 lần, không bộ nhớ
  • Ma trận chế tạo → khớp ghi cứng : mã xấu, không byte
  • zlib → không gì : kết nối tệ = chết, nhưng chơi được
  • Kiểm tra → tin tưởng : không bảo mật, không tính toán

Mỗi tính năng vắng mặt cho phép tính năng khác tồn tại trong giới hạn phần cứng.

3 điểm cần nhớ :

  1. Nội suy + RNG -- 4 điểm seed, địa hình vô hạn, không lưu trữ, truy vấn không cần sinh lại chunk, 200 ms sinh. Đó là nước thiên tài khiến phần còn lại khả thi.
  2. Mỗi tính năng có giá -- Thiếu nén, random tick và kiểm tra là lựa chọn có chủ ý : đó là điều cho phép vừa trong 520 KB.
  3. Mẹo bẩn thông minh nhất -- Rương trong bảng khối qua memcpy, đói bằng gói di chuyển, lò nung tức thì. Giải pháp sạch sẽ quá đắt.

Nếu dự án khiến bạn hứng thú, mọi thứ trên GitHub theo GPLv3. Đó là C bẩn đẹp, và tôi hiếm khi vui vậy khi đọc mã nguồn.

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