Archaeology of a Phenomenon: Analyzing Minecraft rd-132211

The best-selling video game of all time started as 1,562 lines of Java in a 26KB JAR file.

On May 13, 2009, Markus “Notch” Persson compiled a build of something called “RubyDung” — a voxel world tech demo named after an earlier, abandoned project. The version identifier rd-132211 (probably a timestamp: 1:22 PM, the 11th build?) marks the very first entry in Minecraft’s official version manifest. It is the earliest playable artifact of what would become the most successful indie game ever made, eventually selling over 300 million copies.

Today we are going to take it apart, line by line.

The entire game fits in 13 Java files across three packages, all under com.mojang.rubydung. There is no Minecraft here yet — no crafting, no survival, no creepers. What exists is a flat world made of blocks that you can destroy and place. And yet, reading this code, you can already see the skeleton of the game that would consume a generation. The physics engine, the collision system, the block storage, even the save format — they are all here, in embryonic form, waiting to evolve.

Architecture: Three Packages, Thirteen Files

The full class inventory:

com.mojang.rubydung/
    HitResult.java       - Ray-block intersection result
    Player.java          - Player entity with physics
    RubyDung.java        - Main class, game loop, entry point
    Textures.java        - Texture loading utility
    Timer.java           - Fixed-timestep game timer

com.mojang.rubydung.level/
    Chunk.java           - Renderable 16x16x16 section
    Frustum.java         - View frustum culling
    Level.java           - World data (the block array)
    LevelListener.java   - Observer interface for level changes
    LevelRenderer.java   - Renders the level using chunks
    Tesselator.java      - Vertex batching for OpenGL
    Tile.java            - Block type rendering

com.mojang.rubydung.phys/
    AABB.java            - Axis-aligned bounding box

The separation is already telling. Game logic (Player, Level, AABB) lives apart from rendering (Chunk, LevelRenderer, Tesselator, Frustum). The Level class knows nothing about OpenGL. The Player class knows nothing about textures. For a “quick prototype,” this is remarkably clean architecture.

The Game Loop: Surprisingly Sophisticated

RubyDung implements Runnable and launches on its own thread:

public static void main(String[] args) throws LWJGLException {
    new Thread(new RubyDung()).start();
}

The game loop uses a fixed-timestep timer at 60 ticks per second with render interpolation — a technique you would find in any modern game engine tutorial, but one that many hobby projects skip entirely:

while (!Keyboard.isKeyDown(1) && !Display.isCloseRequested()) {
    this.timer.advanceTime();

    for (int i = 0; i < this.timer.ticks; i++) {
        this.tick();
    }

    this.render(this.timer.a);
    // ...
}

The Timer class accumulates elapsed nanoseconds, converts them to tick counts at the configured rate, and stores the fractional remainder in a field cryptically named a. That fractional value is the interpolation alpha — it gets passed to render() so the camera can smoothly interpolate the player’s position between physics ticks:

float x = this.player.xo + (this.player.x - this.player.xo) * a;
float y = this.player.yo + (this.player.y - this.player.yo) * a;
float z = this.player.zo + (this.player.z - this.player.zo) * a;

Timer.a — Notch’s naming convention at its finest. Every previous-frame position (xo, yo, zo) exists specifically for this interpolation. The physics runs at a fixed 60 Hz regardless of frame rate, and the renderer smoothly blends between states. This is the correct way to build a game loop, and Notch had it right from day one.

The timer also includes safety rails: elapsed time is clamped to a maximum of one second (MAX_NS_PER_UPDATE = 1000000000L), and tick count is capped at 100 per frame. If the game hangs or the system clock jumps, the simulation does not try to catch up by running thousands of ticks at once.

The Level: A Flat World in a Byte Array

The world is 256 blocks wide, 256 blocks deep (horizontal), and 64 blocks tall. It is stored as a single flat byte[]:

this.blocks = new byte[w * h * d];

for (int x = 0; x < w; x++) {
    for (int y = 0; y < d; y++) {
        for (int z = 0; z < h; z++) {
            int i = (y * this.height + z) * this.width + x;
            this.blocks[i] = (byte)(y <= d * 2 / 3 ? 1 : 0);
        }
    }
}

World generation is one line: y <= d * 2 / 3 ? 1 : 0. If the Y coordinate is in the bottom two-thirds of the world, it is a solid block. Otherwise, air. No Perlin noise, no biomes, no caves, no ore distribution. Just a flat plane of stone at Y=42 (two-thirds of 64).

The block index formula (y * height + z) * width + x is worth pausing on. This Y-major ordering means that vertical columns of blocks are contiguous in memory — a layout choice that optimizes for the most common access pattern in a voxel game (vertical neighbor checks for lighting, gravity, block placement). Variations of this indexing formula will persist throughout Minecraft’s entire history.

The naming is slightly confusing: width and height are the horizontal dimensions (256 each), while depth is the vertical extent (64). This naming convention will eventually be rationalized in later versions, but here in the beginning, height means the Z-axis horizontal extent.

Player Physics: The Embryonic Minecraft Feel

The Player.tick() method contains the seed of everything that makes Minecraft movement feel the way it does:

public void tick() {
    this.xo = this.x;
    this.yo = this.y;
    this.zo = this.z;
    float xa = 0.0F;
    float ya = 0.0F;

    if (Keyboard.isKeyDown(200) || Keyboard.isKeyDown(17)) { ya--; }  // W / Up
    if (Keyboard.isKeyDown(208) || Keyboard.isKeyDown(31)) { ya++; }  // S / Down
    if (Keyboard.isKeyDown(203) || Keyboard.isKeyDown(30)) { xa--; }  // A / Left
    if (Keyboard.isKeyDown(205) || Keyboard.isKeyDown(32)) { xa++; }  // D / Right

    if ((Keyboard.isKeyDown(57) || Keyboard.isKeyDown(219)) && this.onGround) {
        this.yd = 0.12F;
    }

    this.moveRelative(xa, ya, this.onGround ? 0.02F : 0.005F);
    this.yd = (float)(this.yd - 0.005);
    this.move(this.xd, this.yd, this.zd);
    this.xd *= 0.91F;
    this.yd *= 0.98F;
    this.zd *= 0.91F;
    if (this.onGround) {
        this.xd *= 0.8F;
        this.zd *= 0.8F;
    }
}

The constants tell a story:

  • Gravity: 0.005 units/tick downward. At 60 TPS, that is 0.3 units/second/second.
  • Jump impulse: 0.12 units/tick upward. Combined with gravity, this produces a jump arc.
  • Ground acceleration: 0.02 speed multiplier when grounded.
  • Air acceleration: 0.005 — four times weaker than ground control. This is where Minecraft’s floaty air-strafing comes from.
  • Air friction: 0.91 on XZ, 0.98 on Y. Horizontal drag plus weaker vertical drag produces the characteristic parabolic jumps.
  • Ground friction: an additional 0.8 multiplier on XZ. Grounded movement is noticeably stickier.

The moveRelative method transforms input-relative movement into world-space velocity using the player’s yaw rotation:

float sin = (float)Math.sin(this.yRot * Math.PI / 180.0);
float cos = (float)Math.cos(this.yRot * Math.PI / 180.0);
this.xd += xa * cos - za * sin;
this.zd += za * cos + xa * sin;

Standard 2D rotation matrix applied to the horizontal input vector. Simple, correct, and unchanged in principle through every version of Minecraft that followed.

The Collision System: Sweep and Prune

The move() method on Player implements the collision resolution that would survive, in evolved form, to the present day. The approach is axis-separated collision: resolve Y first, then X, then Z.

public void move(float xa, float ya, float za) {
    float xaOrg = xa;
    float yaOrg = ya;
    float zaOrg = za;
    List<AABB> aABBs = this.level.getCubes(this.bb.expand(xa, ya, za));

    for (int i = 0; i < aABBs.size(); i++) {
        ya = aABBs.get(i).clipYCollide(this.bb, ya);
    }
    this.bb.move(0.0F, ya, 0.0F);

    for (int i = 0; i < aABBs.size(); i++) {
        xa = aABBs.get(i).clipXCollide(this.bb, xa);
    }
    this.bb.move(xa, 0.0F, 0.0F);

    for (int i = 0; i < aABBs.size(); i++) {
        za = aABBs.get(i).clipZCollide(this.bb, za);
    }
    this.bb.move(0.0F, 0.0F, za);

    this.onGround = yaOrg != ya && yaOrg < 0.0F;
    // ...
}

The algorithm: expand the player’s bounding box by the attempted movement vector, query the level for all solid block AABBs that overlap, then iteratively clip the movement against each one. Y is resolved first (so gravity “lands” you before horizontal sliding), then X, then Z. After each axis is resolved, the bounding box is moved to the new position before resolving the next axis.

Ground detection is elegant: this.onGround = yaOrg != ya && yaOrg < 0.0F — if the player was trying to move downward but the collision system said “no,” the player must be standing on something.

The AABB: 153 Lines of Physics Engine

The AABB class in com.mojang.rubydung.phys is, functionally, the entire physics engine. It provides clipXCollide, clipYCollide, and clipZCollide — three methods that clip a movement amount against a solid bounding box along a single axis.

Each clip method follows the same pattern: check if the two boxes overlap on the other two axes. If they do not, there is no collision. If they do, clamp the movement to prevent penetration:

public float clipXCollide(AABB c, float xa) {
    if (c.y1 <= this.y0 || c.y0 >= this.y1) {
        return xa;
    } else if (!(c.z1 <= this.z0) && !(c.z0 >= this.z1)) {
        if (xa > 0.0F && c.x1 <= this.x0) {
            float max = this.x0 - c.x1 - this.epsilon;
            if (max < xa) {
                xa = max;
            }
        }
        if (xa < 0.0F && c.x0 >= this.x1) {
            float max = this.x1 - c.x0 + this.epsilon;
            if (max > xa) {
                xa = max;
            }
        }
        return xa;
    } else {
        return xa;
    }
}

Note the epsilon field: private float epsilon = 0.0F. It is initialized to zero and never modified. The clipping math uses it — this.x0 - c.x1 - this.epsilon — but since it is zero, it has no effect. Notch added the infrastructure for a collision skin distance but never needed to activate it. This is the kind of detail that tells you about intent: he knew he might need sub-pixel collision margins later. He was thinking ahead, even in a prototype.

The class also provides expand (grow the box in the direction of movement, used for broadphase queries), grow (expand uniformly in all directions), intersects (boolean overlap test), and move (translate the box). Simple, focused, correct.

Block Interaction: OpenGL Selection Buffer Picking

Block selection uses the OpenGL selection buffer — a technique that was already considered deprecated by 2009. The idea: switch OpenGL into “selection mode,” render the scene with each block face tagged using the GL name stack, then read back the hit records to find what the crosshair is pointing at.

// In RubyDung.pick():
GL11.glRenderMode(7170);  // GL_SELECT
this.setupPickCamera(a, this.width / 2, this.height / 2);
this.levelRenderer.pick(this.player);
int hits = GL11.glRenderMode(7168);  // GL_RENDER

The pick camera uses gluPickMatrix centered on the screen to create a tiny frustum around the crosshair. The LevelRenderer.pick() method then renders every solid block face within reach (3 blocks), pushing block coordinates and face indices onto the name stack. The closest hit becomes the HitResult.

Block interaction itself is simple: right-click (button 1) destroys, left-click (button 0) places. The face index f (0 through 5, mapping to -Y, +Y, -Z, +Z, -X, +X) determines the placement offset:

if (this.hitResult.f == 0) { y--; }
if (this.hitResult.f == 1) { y++; }
if (this.hitResult.f == 2) { z--; }
if (this.hitResult.f == 3) { z++; }
if (this.hitResult.f == 4) { x--; }
if (this.hitResult.f == 5) { x++; }
this.level.setTile(x, y, z, 1);

This six-face system, with integer face IDs mapping to axis-aligned directions, persists in concept through the entire evolution of the game. The HitResult class (5 fields: x, y, z, o, f) also contains a mysterious o field that appears to be unused — perhaps “orientation” or “object,” another forward-looking placeholder.

Lighting: A Heightmap Shortcut

Lighting in rd-132211 is not a flood-fill or a ray trace. It is a heightmap. For each XZ column, the Level finds the highest solid block. Everything below it gets brightness 0.8; everything above gets brightness 1.0.

public float getBrightness(int x, int y, int z) {
    float dark = 0.8F;
    float light = 1.0F;
    if (x < 0 || y < 0 || z < 0 || x >= this.width || y >= this.depth || z >= this.height) {
        return light;
    } else {
        return y < this.lightDepths[x + z * this.width] ? dark : light;
    }
}

The renderer uses two layers to handle this: layer 0 draws faces at full brightness (1.0), layer 1 draws faces at shadow brightness (0.8). The selection between layers uses a clever XOR trick in the Tile render method: if (br == c1 ^ layer == 1) — a face is rendered in a given layer only if its brightness matches that layer. This means lit faces are drawn in one pass and shadowed faces in another, which allows the renderer to apply different fog settings or blend modes per layer.

This is not fancy, but it is elegant for what it needs to do: provide basic depth cues so the player can tell what is a surface and what is a shadow.

The Save System: Beautiful Simplicity

Saving and loading the world is possibly the most elegant code in the entire project:

public void save() {
    DataOutputStream dos = new DataOutputStream(
        new GZIPOutputStream(new FileOutputStream(new File("level.dat"))));
    dos.write(this.blocks);
    dos.close();
}

public void load() {
    DataInputStream dis = new DataInputStream(
        new GZIPInputStream(new FileInputStream(new File("level.dat"))));
    dis.readFully(this.blocks);
    this.calcLightDepths(0, 0, this.width, this.height);
    // notify listeners...
    dis.close();
}

Gzip the byte array. Write it to level.dat. That is the entire save format. No NBT, no chunk-based storage, no region files, no player data. The block array is 256 * 256 * 64 = 4,194,304 bytes uncompressed. With gzip and the fact that most of the array is either 0x00 (air) or 0x01 (stone), it compresses dramatically.

The filename level.dat persists through Minecraft’s entire history, though its contents will eventually become an NBT compound tag of enormous complexity. Here, it is just raw bytes.

Interesting Observations

Un-obfuscated source. Because this predates any official release or obfuscation pipeline, we see Notch’s original variable names. They are terse — xo, yo, zo for previous-frame positions; xd, yd, zd for velocity; xa, ya, za for acceleration — but consistent. This naming convention survives into later versions even after obfuscation, visible through mapping projects like MCP and Yarn.

Magic numbers everywhere. OpenGL constants are hardcoded as integers: 3553 is GL_TEXTURE_2D, 7425 is GL_SMOOTH, 2929 is GL_DEPTH_TEST, 5889 is GL_PROJECTION, 5888 is GL_MODELVIEW. Notch clearly had these memorized or was copying from a reference. The LWJGL GL11 class provides named constants, but they are not used.

The Frustum class is a textbook implementation. The m_Frustum field name (using m_ prefix Hungarian notation) and the methodical extraction-of-clip-planes-from-the-MVP-matrix approach match OpenGL frustum culling tutorials that circulated widely in the early 2000s. This was almost certainly adapted from existing reference code rather than derived from scratch.

Two tile types, structurally identical. Tile.rock and Tile.grass differ only in texture index (0 vs. 1). They are not subclasses; they are two instances of the same class. The Chunk.rebuild method decides which to use based on whether a block is at the surface layer: int tex = y == this.level.depth * 2 / 3 ? 0 : 1. Blocks at exactly the fill height are rock; everything below is grass. (Yes, this means grass is underground and rock is the surface. The texture assignment feels backwards, or perhaps the textures in terrain.png were ordered differently than one might expect.)

The Tesselator pattern. The vertex batching system in Tesselator — accumulate vertices into a buffer, flush them as a single glDrawArrays call — is the ancestor of what becomes Minecraft’s rendering backbone. The 100,000-vertex capacity, the separate position/texcoord/color buffers, the flush-on-full behavior: this is already a miniature rendering engine.

The LevelListener pattern. Even in the first version, the level uses an observer pattern. LevelListener defines three events: tileChanged, lightColumnChanged, and allChanged. The LevelRenderer implements this interface to know when to mark chunks dirty for rebuild. This decoupling between data and presentation is why the game logic can stand alone without the renderer — and why our Rust port can skip rendering entirely.

Implications for the Rust Port

The clean separation between game logic and rendering in rd-132211 is a gift. We need to port:

  • Level — the block array, world gen, lighting heightmap, tile get/set, collision query (getCubes), save/load
  • Player — physics tick, movement, collision resolution, mouse look
  • AABB — the bounding box math that underpins the collision system
  • HitResult — a trivial data struct
  • Timer — fixed-timestep logic (adapted since we will not have a render loop)

We skip entirely:

  • RubyDung (the main class) — OpenGL setup, render loop, input polling
  • Chunk, LevelRenderer, Tile — all rendering code
  • Tesselator — vertex batching for OpenGL
  • Frustum — view frustum culling
  • Textures — texture loading

That leaves us with roughly 5 of 13 files to port. The game logic core — the part that determines “what happens” when a player moves, a block is placed, or the world is saved — is self-contained and well-defined. Every behavior is testable: we can verify block placement, collision detection, gravity, jump arcs, and save/load round-trips without ever touching a rendering API.

This is 1,562 lines of code that launched an empire. Not because they were brilliant (some are quite janky), but because they were sufficient. They solved the right problems — a world you can change, physics that feel embodied, a save system that works — and nothing more. Everything else could come later.

Seventeen years and 300 million copies later, the bones of RubyDung are still visible in the game that consumed the world. The collision system still resolves axes in Y-X-Z order. The world still stores blocks in a flat array indexed by position. The timer still separates simulation from rendering.

The best code is not clever code. It is code that ships, survives, and evolves. These 1,562 lines did all three.

By Clara

Leave a Reply

Your email address will not be published. Required fields are marked *