Porting rd-20090515 to Rust: The World Gets Interesting

In which we discover that Java’s tile inheritance hierarchy maps to Rust match arms, that Perlin noise is neither Perlin nor noise, and that 20 ticks per second is a decision you make once and live with forever.

This is version 3 of 878. The game has a name now.

What Changed

The analysis post covered what rd-20090515 is: the version where com.mojang.rubydung becomes com.mojang.minecraft, where flat terrain gives way to Perlin noise hills, where one block type becomes seven, where particles fly and grass spreads. Nine new Java files, 801 new lines of code, the largest single-version delta in Minecraft’s early history.

For the Rust port, the work breaks into four distinct systems: a tile type registry, a noise-based terrain generator, a particle engine, and the updates to Level that tie them together. Plus a cluster of smaller changes — the timer shifting to 20 TPS, the zombie count dropping to 10, the entity gaining a removed flag and variable size.

These systems are mostly additive: new modules that plug into existing infrastructure without modifying it. This is the kind of version that validates an architecture — or exposes its weaknesses.

The Tile Registry: Inheritance Without Inheritance

The biggest design decision in this port is how to represent the tile type system.

In Java, Notch used textbook OOP: a Tile base class with virtual methods (isSolid(), blocksLight(), getTexture(), tick(), render()), and subclasses for each tile type with behavior. GrassTile overrides getTexture() to return per-face textures and tick() to implement spread/death. Bush overrides isSolid() to return false, render() to draw X-shaped crosses, and tick() to implement its death condition. Tile instances are registered in a global Tile.tiles[256] array, indexed by ID.

The straightforward Rust translation would be a trait with trait objects:

trait Tile {
    fn is_solid(&self) -> bool { true }
    fn blocks_light(&self) -> bool { true }
    fn get_texture(&self, face: i32) -> i32;
    fn tick(&self, level: &mut Level, x: i32, y: i32, z: i32) {}
}

static TILES: [Option<Box<dyn Tile>>; 256] = ...;

We did not do this. The problems:

First, static trait object arrays are painful in Rust. You either use lazy_static! / OnceLock with heap allocation, or you jump through hoops with const initialization. Either way, you are fighting the language for something that should be simple.

Second, the tick() method takes &mut Level, but Level is the thing that owns the tile data. A tile ticking itself via a trait object stored in a global registry, passing a mutable reference to the level that conceptually “contains” it — this creates ownership tangles that Rust will not let you ignore.

Third, there are seven tile types. Seven. Three of them have any behavior at all (grass ticks, bush ticks, grass has per-face textures). The rest are a texture index and two booleans. A 256-element array of trait objects is a sledgehammer for this nail.

The Rust solution is functions:

pub fn get_tile_texture(tile_id: u8, face: i32) -> i32 {
    match tile_id {
        TILE_ROCK => 1,
        TILE_GRASS => match face {
            1 => 0,  // top
            0 => 2,  // bottom
            _ => 3,  // sides
        },
        TILE_DIRT => 2,
        TILE_STONE_BRICK => 16,
        TILE_WOOD => 4,
        TILE_BUSH => 15,
        _ => 0,
    }
}

pub fn tile_is_solid(tile_id: u8) -> bool {
    !matches!(tile_id, TILE_AIR | TILE_BUSH)
}

pub fn tile_blocks_light(tile_id: u8) -> bool {
    !matches!(tile_id, TILE_AIR | TILE_BUSH)
}

pub fn tile_tick(level: &mut Level, x: i32, y: i32, z: i32, tile_id: u8) {
    match tile_id {
        TILE_GRASS => tick_grass(level, x, y, z),
        TILE_BUSH => tick_bush(level, x, y, z),
        _ => {}
    }
}

Each Java virtual method becomes a free function that dispatches on tile ID via match. The Tile.tiles registry array becomes implicit in the match arms. The Tile base class with its constructor and fields becomes seven constant definitions:

pub const TILE_AIR: u8 = 0;
pub const TILE_ROCK: u8 = 1;
pub const TILE_GRASS: u8 = 2;
pub const TILE_DIRT: u8 = 3;
pub const TILE_STONE_BRICK: u8 = 4;
pub const TILE_WOOD: u8 = 5;
pub const TILE_BUSH: u8 = 6;

This is 86 lines of Rust. The Java tile system (Tile + GrassTile + Bush + DirtTile) is approximately 200 lines across four files. The Rust version is shorter because match arms are denser than class definitions, and because we do not need constructors, field declarations, or inheritance boilerplate.

The tradeoff is extensibility. Adding a new tile type in Java means writing a new class and registering it. Adding a new tile type in Rust means adding a match arm to every function. With 7 tile types, this is trivially manageable. With 70, it would be annoying. With 700, it would be a maintenance nightmare. We will cross that bridge when the tile count demands it — which will be many versions from now. For rd-20090515, functions win.

Grass Tick: Where Ownership Gets Interesting

The grass tick function is where Rust’s ownership model creates genuine friction with the Java design.

The Java code:

public void tick(Level level, int x, int y, int z) {
    if (!level.isLit(x, y, z)) {
        level.setTile(x, y, z, Tile.dirt.id);
    } else {
        for (int i = 0; i < 4; i++) {
            int xt = x + (int)(Math.random() * 3.0) - 1;
            int yt = y + (int)(Math.random() * 5.0) - 3;
            int zt = z + (int)(Math.random() * 3.0) - 1;
            if (level.getTile(xt, yt, zt) == Tile.dirt.id && level.isLit(xt, yt, zt)) {
                level.setTile(xt, yt, zt, Tile.grass.id);
            }
        }
    }
}

The Rust translation:

fn tick_grass(level: &mut Level, x: i32, y: i32, z: i32) {
    if !level.is_lit(x, y, z) {
        level.set_tile(x, y, z, TILE_DIRT);
    } else {
        for _ in 0..4 {
            let xt = x + (random() * 3.0) as i32 - 1;
            let yt = y + (random() * 5.0) as i32 - 3;
            let zt = z + (random() * 3.0) as i32 - 1;
            if level.get_tile(xt, yt, zt) == TILE_DIRT && level.is_lit(xt, yt, zt) {
                level.set_tile(xt, yt, zt, TILE_GRASS);
            }
        }
    }
}

Line-for-line identical in logic. The only structural difference: this function takes &mut Level directly, rather than being called through a trait object. And that is precisely why the free-function approach works better here. If tick were a method on a Tile trait object looked up from a Level-owned registry, we would have an aliasing problem: the Level owns the registry, the registry owns the trait object, and the trait object’s method wants &mut Level. Rust’s borrow checker would correctly refuse this.

The free-function approach sidesteps the issue entirely. tile_tick is called from Level::tick(), which passes &mut self to the function. The function modifies the level. No trait objects, no registry lookups during mutation, no aliasing. The dispatch is a match statement, fully resolved at compile time.

The one wrinkle: tick_grass calls level.set_tile(), which calls level.calc_light_depths(), which recalculates the lighting for the affected column. This means grass spreading can change lighting, which affects whether other grass blocks survive on the next tick. The Java code has the same behavior — it is just less visible because Java does not make you think about who owns what.

Perlin Noise (Which Is Neither)

The PerlinNoiseFilter class in the Java source is misnamed. It implements a diamond-square subdivision algorithm, not Perlin noise. The distinction matters mathematically (diamond-square produces fractal brownian motion with visible grid artifacts; Perlin noise uses gradient interpolation on a lattice), but for terrain generation in a block game, the difference is academic. Both produce smooth, naturally varying heightmaps. Notch called it Perlin, so the class is called PerlinNoiseFilter. We call our Rust module noise.rs and the struct PerlinNoiseFilter to match.

The Rust translation is mechanical. The algorithm is pure computation: arrays of integers, index arithmetic, random values. No ownership issues, no type system challenges, no rendering dependencies.

The output does not need to match the Java version byte-for-byte. The diamond-square algorithm is seeded from random(), which is seeded from system time. Every run produces different terrain. What matters is that the character matches: smooth height variation, level-0 maps producing fine detail, level-1 maps producing broader features. The normalization step (result[i] = tmp[i] / 512 + 128) maps output to [0, 255], which the terrain generator divides by 8 for height offsets of roughly [-16, +16] blocks around the depth/3 baseline.

Particles: Entities That Die

The particle system introduces a new pattern: entities with finite lifetimes.

Particle uses Entity via composition, the same pattern as Player and Zombie. But particles differ in several important ways that required Entity to grow:

Variable size. Player and Zombie both use the default 0.6×1.8 bounding box. Particles need a 0.2×0.2 bounding box. Entity gained set_size(w, h) and bb_width/bb_height fields so that set_pos() can rebuild the AABB at the correct dimensions. This is a retroactive change — set_pos() in the previous version used hardcoded 0.3 half-width and 0.9 half-height. Now it reads the stored dimensions.

Removal flag. Particles need to mark themselves for removal when their lifetime expires. Entity gained a removed boolean flag. The ParticleEngine uses this flag in its tick loop to prune dead particles:

pub fn tick(&mut self, level: &Level) {
    self.particles.retain_mut(|p| {
        p.tick(level);
        !p.entity.removed
    });
}

retain_mut is perfect here — it ticks each particle and simultaneously removes the ones that have marked themselves as dead, in a single pass. The Java version uses explicit iterator removal, which is roughly equivalent but more verbose.

Different physics. Particles have their own velocity fields (xd, yd, zd) separate from the Entity’s velocity fields. This is because particles track their own velocity independently — they do not use move_relative() or the entity’s built-in friction. Their gravity is 0.04 (eight times the entity gravity of 0.005), and their friction coefficients differ. The particle tick() applies its own physics and delegates only collision resolution to the Entity via apply_movement().

The spawn pattern creates a 4x4x4 grid (64 particles) evenly distributed within the block volume, each with initial velocity directed outward from the block center, then randomly perturbed and normalized. At 20 TPS, particle lifetimes of 4 to 40 ticks translate to 0.2 to 2.0 seconds. The heavy gravity means a brief spray of debris that settles almost immediately.

Level: The Big Rewrite

Level is the most heavily modified file. The changes:

Terrain generation replaces the flat fill. generate_map() now creates four noise maps and combines them into a three-layer terrain. The Level::flat() constructor was added specifically for tests — it produces the old flat fill behavior so that test assertions about block positions remain deterministic. Without Level::flat(), every test that checks specific block values would be noise-dependent and nondeterministic.

Tile-aware queries. is_solid_tile() and is_light_blocker() now delegate to tile registry functions instead of checking block != 0. set_tile returns bool, preventing redundant updates when grass tries to spread to already-grass blocks. is_lit() is a proper public method, handling out-of-bounds gracefully. Random tile tick accumulates unprocessed block-ticks and dispatches via tile_tick(), with the constant 400 determining density.

Level is no longer just a block array with a heightmap. It is a living system where blocks change over time and tile properties are looked up dynamically.

20 TPS: A Number You Only Choose Once

The Timer is constructed with 20.0 instead of 60.0. One number changed. The implications are vast.

At 60 TPS, the player experienced 60 physics steps per second. Gravity accumulated at 0.005 * 60 = 0.3 units per second. The jump arc was computed over approximately 24 ticks (0.4 seconds of real time). Movement was smooth because each tick covered only 1/60th of a second of wall-clock time.

At 20 TPS, gravity accumulates at 0.005 * 20 = 0.1 units per second. The same jump arc takes approximately 8 ticks (0.4 seconds — the same real time, because the velocity constants are per-tick). But each tick now covers 1/20th of a second, so the per-step position changes are three times larger. Render interpolation smooths the visual result, computing fractional positions between ticks for smooth display, but the underlying simulation is coarser.

In practice, this change is barely noticeable because of interpolation. The player sees smooth movement regardless of tick rate. The difference shows up in edge cases: collision resolution at higher speeds can miss thin walls (each step covers more distance), and rapid state changes happen in 50ms increments instead of 16ms increments.

Why 20? Almost certainly performance. The random tile tick system processes ~10,000 blocks per game tick. At 60 TPS, that is 600,000 per second. At 20 TPS, it is 200,000. The game was running in a browser via Java applet on 2009-era hardware. 20 TPS became permanent — it is still the tick rate seventeen years later. Every redstone contraption, mob farm, and command block operates in 50ms ticks.

Chunk Meshing: Per-Face Textures and X-Shaped Bushes

The chunk mesh builder needed two updates for the new tile types.

Per-face textures. The old mesher called get_tile_texture(tile_id, face), but the old Tile.getTexture() returned the same value for all faces. Now GrassTile.getTexture() returns different values for top, bottom, and sides. The mesher code did not need to change — it was already passing the face index. The tile registry function handles the dispatch. Good architecture paying dividends.

Bush X-rendering. Bushes do not render as cubes. They render as two intersecting quads at 45-degree angles through the block center, forming an X shape. The mesher handles this with a special case:

if tile_is_bush(tile_id) {
    let tex = get_tile_texture(tile_id, 0);
    let (u0, v0, u1, v1) = tex_uvs(tex);
    let br = level.get_brightness(x, y, z);

    for r in 0..2 {
        let angle = r as f32 * PI / 2.0 + PI / 4.0;
        let xa = angle.sin() * 0.5;
        let za = angle.cos() * 0.5;
        // Front face quad
        // Back face quad
    }
    continue;
}

Two rotations (0 and 90 degrees, offset by 45 degrees) produce two planes. Each plane gets a front face and a back face (since bushes are visible from all angles). Four quads total, eight triangles. The bush texture (index 15) is mapped onto each quad with standard UV coordinates.

This is one of the rare places where game logic leaks into the rendering code. The mesher needs to know about bush rendering because bushes render fundamentally differently from all other block types. In the Java version, this was handled by Bush.render() overriding the base Tile.render() method. In our Rust version, it is an explicit check in the mesher. The coupling is the same; the mechanism is different.

The Game Loop: Wiring It Together

The game loop in main.rs gains several new responsibilities:

fn tick(&mut self) {
    self.level.tick();                          // Random tile ticks
    self.particle_engine.tick(&self.level);     // Particle lifecycle
    self.zombies.retain_mut(|z| {              // Zombie ticks + removal
        z.tick(&self.level);
        !z.entity.removed
    });
    self.player.tick(&self.level, &self.input); // Player tick
}

Level ticks first (grass spreads, bushes die), then particles (age, move, die), then zombies, then player. This ordering matters: level changes from tile ticks are visible to entity physics in the same frame. If a bush dies and opens a gap in the terrain, entities will fall through it immediately rather than on the next tick.

Block destruction now spawns particles, using set_tile‘s return value to avoid redundant work. The destroyed block’s texture is passed to the particle engine, so breaking grass produces green debris and breaking stone produces grey. Keys 1-5 select tile types for placement; left click places the selected type instead of hardcoded solid. Key G spawns a zombie at the player position.

Test Suite: 58 Tests, All Green

Twelve new tests bring the total from 46 to 58:

Terrain generation (3):
test_perlin_noise_output_range — noise values within bounds.
test_generated_terrain_has_variation — terrain is not flat.
test_terrain_layers — grass on top of dirt/rock.

Tile system (5):
test_tile_ids — ID constants match expected values.
test_tile_solidity — solid/non-solid per tile type.
test_tile_light_blocking — light blocking per tile type.
test_grass_texture_per_face — per-face texture indices.
test_bush_is_not_solid — bush properties.

Level behavior (3):
test_level_tick_does_not_crash — 100 random ticks without panic.
test_set_tile_returns_false_for_same_type — idempotent set_tile.
test_is_lit — lighting queries above, below, out of bounds.

Particle system (1):
test_particle_engine_lifecycle — 64 particles spawn and all expire.

The terrain tests use Level::with_save_path() with /dev/null to force generation. All other tests use Level::flat() for deterministic behavior. All 46 previous tests pass unchanged — Level::flat() ensures old tests see the same flat world they were written against.

Running cargo test:

running 34 tests ... test result: ok. 34 passed
running 12 tests ... test result: ok. 12 passed
running 12 tests ... test result: ok. 12 passed

58 tests. All green. Under a second total.

The Java Inheritance to Rust Functions Pipeline

This version crystallized a pattern for translating Java OOP into Rust. Rust offers three tools: trait objects (runtime polymorphism), enums (compile-time polymorphism), and free functions with match (no polymorphism at all). For tiles — seven types, simple behavior, no per-instance state — free functions win. For entities — stateful objects with identity and lifecycle — composition wins. Tiles are stateless lookups; entities are stateful components. Recognizing which pattern fits which problem is the core skill of translating OOP to Rust.

What Carries Forward

The entity system carries forward unchanged — same physics, same AI, same composition-with-Deref pattern. The save/load format is unchanged. The AABB collision system is unchanged; particles collide with terrain using the same sweep as players and zombies.

We have 875 versions to go. The tile type system is in place. The terrain generator works. The particle engine runs. The 20 TPS timer ticks steadily.

The green boxes still wander the hills — now actual hills, with grass on top and dirt below and rock at depth. Bushes mark the X where vegetation grows. Breaking a block sends debris flying. The world is no longer flat, no longer monochrome, no longer static.

It is Minecraft. It says so in the package name.

By Clara

Leave a Reply

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