Porting rd-160052 to Rust: Learning to Delete
In which we remove a tile type that was only one version old, discover that subtracting code requires as much care as adding it, and learn that random death is simpler than deterministic death.
This is version 4 of 878. The codebase got shorter.
What Changed
The analysis post covered what rd-160052 is: the version where Notch reverted the package name from com.mojang.minecraft to com.mojang.rubydung, deleted the bush tile and the applet wrapper, tuned the particle system, doubled the crosshair, tripled the block preview, and shipped a game that was 175 lines shorter than the day before.
For the Rust port, this is the first version where the primary task is removal. The previous three ports were pure addition: new modules, new systems, new tests. This time, the work is mostly surgical deletion and parameter adjustment. It sounds easy. It required more care than adding the features did.
Removing Bush: A Tile Dissection
Bush (tile ID 6) touched four systems:
- Tile registry (
tile.rs):TILE_BUSHconstant, match arms inget_tile_texture(),tile_is_solid(),tile_blocks_light(), andtile_tick(). - Chunk mesher (
chunk_mesh.rs): The X-shaped cross rendering special case that detected bush tiles and emitted two intersecting quads instead of a standard cube. - Main game loop (
main.rs): Key 5 bound to bush placement. - Tests (
rd_20090515_parity.rs):test_bush_is_not_solidasserted bush properties.
Removing code is not the inverse of adding code. When you add a feature, you write code and then verify it works. When you remove a feature, you delete code and then verify that everything else still works. The blast radius of deletion is often wider than the blast radius of addition, because other code may depend on the removed code in ways that are not immediately obvious.
The bush removal was straightforward because bush was well-isolated. It had its own constant, its own match arms, its own rendering path, and its own test. No other tile depended on bush. No game logic checked for bush. Nothing read from tile ID 6 except the code that implemented bush. This is the payoff of the function-based tile system we chose in rd-20090515: each tile’s behavior lives in match arms that can be removed independently.
But one subtlety emerged: tile_is_solid() and tile_blocks_light().
In the rd-20090515 port, these functions used a deny-list approach:
pub fn tile_is_solid(tile_id: u8) -> bool {
!matches!(tile_id, TILE_AIR | TILE_BUSH)
}
“Everything is solid except air and bush.” With bush removed, the naive edit is to just drop TILE_BUSH from the match:
pub fn tile_is_solid(tile_id: u8) -> bool {
!matches!(tile_id, TILE_AIR)
}
“Everything is solid except air.” But this is a deny-list, and deny-lists are dangerous after deletions. With this formulation, any unregistered tile ID (6, 7, 42, 255) would be treated as solid. The Java version’s Tile.tiles[] array returns null for unregistered IDs, and the Java code checks for null before querying properties. Our Rust port has no registry array, so unregistered IDs would silently fall through to the default.
The correct fix is to switch from a deny-list to an allow-list:
pub fn tile_is_solid(tile_id: u8) -> bool {
matches!(tile_id, TILE_ROCK | TILE_GRASS | TILE_DIRT | TILE_STONE_BRICK | TILE_WOOD)
}
“These five tiles are solid. Everything else — air, unregistered IDs, whatever — is not.” This is more explicit, harder to get wrong when tiles are added or removed, and correctly handles the case where someone passes a garbage tile ID. The deny-list version was fine when we had two exceptions to enumerate. After bush removal, the allow-list version is clearer and safer.
The same logic applies to tile_blocks_light().
This is a small change, but it illustrates a principle: when you remove code, re-examine the assumptions that the remaining code was built on. The deny-list approach assumed “all tile IDs are either known-solid or known-exception.” That assumption was valid for rd-20090515 and its seven tile types. It is less valid for rd-160052, where ID 6 is an empty slot. Allow-lists are more robust under deletion.
Particle System: Simpler Physics, Better Feel
The particle changes were more extensive than the bush removal, but conceptually simpler: adjust numbers and replace one death mechanism with another.
Random Death Replaces Deterministic Lifetime
The rd-20090515 particle had:
pub struct Particle {
// ...
pub age: i32,
pub lifetime: i32,
}
pub fn tick(&mut self, level: &Level) {
self.age += 1;
if self.age >= self.lifetime {
self.entity.remove();
}
// ... physics ...
}
The rd-160052 particle has:
pub struct Particle {
// ... (no age or lifetime fields)
}
pub fn tick(&mut self, level: &Level) {
self.entity.save_prev_pos();
if random() < 0.1 {
self.entity.remove();
}
// ... physics ...
}
Two fields removed. One conditional replaced with another. The struct gets smaller and the logic gets simpler. In exchange, particle lifetime becomes a random variable with a geometric distribution (expected value: 10 ticks) instead of a predetermined constant.
The implementation is trivial, but the test implications are interesting. You cannot test “particle dies after exactly N ticks” when death is stochastic. Instead, the tests verify statistical properties:
test_particle_dies_eventually: Run up to 200 ticks. With p=0.1 death per tick, the probability of surviving 200 ticks is 0.9^200 which is effectively zero. If the particle is still alive, something is wrong.test_particle_engine_random_death: Spawn 64 particles, run 200 ticks. All should be dead. Same reasoning: the probability of any single particle surviving 200 ticks is negligible, and the probability of all 64 being dead is (1 – 0.9^200)^64 which is indistinguishable from 1.test_particle_heavier_gravity: This one is tricky because the particle might die on its first tick (10% chance). The test creates up to 20 particles and checks the first one that survives. With a 90% survival rate per attempt, the probability of zero survivors in 20 attempts is 0.1^20 — negligible.
Testing stochastic systems requires thinking about probabilities rather than exact values. The tests are designed so that false negatives are astronomically unlikely while still being meaningfully different from “always passes.”
Heavier Gravity
Gravity changed from 0.04 to 0.06. One constant, one line:
self.yd -= 0.06;
The test checks that after one tick, yd is less than -0.05 (it starts near zero from the random initial velocity, then has 0.06 subtracted, then is multiplied by 0.98 drag). The threshold is loose enough to accommodate the random initial velocity but tight enough to distinguish 0.06 gravity from 0.04 gravity.
Speed Formula
The initial velocity normalization changed:
// rd-20090515:
xd = xd / dd * speed * 0.4;
yd = yd / dd * speed * 0.4 + 0.1;
zd = zd / dd * speed * 0.4;
// rd-160052:
xd = xd / dd * speed * 0.7;
yd = yd / dd * speed;
zd = zd / dd * speed * 0.7;
Horizontal speed factor went from 0.4 to 0.7 (wider spread). Vertical speed factor went from 0.4 to 1.0 (unattenuated). The 0.1 upward bias was removed (no fountain effect). The net result: debris sprays wider and faster, with no preferred vertical direction.
These changes are not individually testable in a meaningful way — the speed values are buried inside the constructor and immediately mixed with random perturbations. The gravity and death tests serve as smoke tests for the overall particle physics; the speed changes are verified by visual inspection in the running game.
Chunk Mesh: Removing the Exception
The chunk mesher in rd-20090515 had a special case:
if tile_is_bush(tile_id) {
// X-shaped cross rendering: two intersecting quads
// ... 30+ lines of geometry generation ...
continue;
}
With bush gone, this entire block is deleted. The mesher returns to a clean loop: for each non-air tile, render six faces with neighbor culling. No special cases. No branches on tile type.
This is the most satisfying kind of deletion: removing a conditional that was the only reason for complexity. The mesher is now a straightforward nested loop with no tile-type-dependent rendering paths. When non-solid tiles return in future versions, they will likely need a fresh implementation anyway — the bush rendering code was specific to bush’s X-shaped geometry and would not generalize to other non-solid tiles like flowers or torches.
Key Binding: 5 Is Wood Now
In rd-20090515, keys 1-5 mapped to: rock, dirt, stone brick, wood, bush. With bush removed, key 5 maps to wood. This is not “keys 1-4 with nothing on 5” — it is an explicit remapping. The key 5 binding still exists; it just selects a different tile.
In the Rust port, this is a one-line change in the input handling:
// rd-20090515:
KeyCode::Digit5 => self.paint_texture = TILE_BUSH,
// rd-160052:
KeyCode::Digit5 => self.paint_texture = TILE_WOOD,
Trivial in implementation, but the design decision is interesting. Why not remove key 5 entirely? Because key 5 was a valid input in the previous version, and players (even if the only player is Notch) have muscle memory. Remapping to wood gives key 5 a function without introducing a new block type. The user’s expectation that “5 selects something” is preserved even though the specific something changed.
HUD Adjustments
Crosshair: +-8/9 Pixels
The crosshair expanded from +-4/5 pixels to +-8/9 pixels. In the renderer, this is a coordinate change in the vertex data:
// Vertical bar (+-8/9 matching rd-160052)
HighlightVertex { position: [cx + 1.0, cy - 8.0, 0.0] },
HighlightVertex { position: [cx, cy - 8.0, 0.0] },
HighlightVertex { position: [cx, cy + 9.0, 0.0] },
// ...
The asymmetry (8 pixels in one direction, 9 in the other) produces a crosshair that is 17 pixels tall/wide with the center pixel at the exact screen center. The old crosshair was 9 pixels (4 + 1 + 4). The new one is 17 pixels (8 + 1 + 8). This is consistent with the fullscreen default: at 1920×1080, a 9-pixel crosshair is barely visible. A 17-pixel crosshair is comfortable.
Block Preview: Scale 48
The block preview tripled from scale 16 to scale 48. The preview renders a small isometric view of the currently selected block type. At scale 16, it was a tiny icon. At scale 48, it is a recognizable block.
This change matters more than it might seem. With five selectable block types, the player needs to know which one is active. A tiny preview that requires squinting defeats the purpose of a preview. A larger preview communicates state at a glance.
The Fullscreen Question
FULLSCREEN_MODE = true is the one behavioral change we did not port. The Java version launches in fullscreen. Our Rust version does not.
This is a deliberate deviation, not an oversight. Fullscreen mode in a development build is actively harmful: it captures the mouse, covers the terminal, and makes it impossible to interact with debugging tools. The game needs to run in a window during development. The fullscreen flag is noted in the gap analysis and can be added as a runtime option when the project is mature enough for release builds.
This is a useful example of the difference between “parity with the Java source” and “correct behavior for the Rust port.” The Java source is the specification, not the law. When the specification says something that would make development worse, the port documents the deviation and moves on.
Test Suite: 65 Tests, All Green
Eight new tests bring the total from 57 to 65:
Bush removal (3):
– test_no_bush_tile — tile ID 6 is not solid or light-blocking.
– test_tile_registry_unchanged — remaining tile IDs still correct.
– test_all_remaining_tiles_solid — all non-air tiles are solid (no more exceptions).
Particle changes (3):
– test_particle_dies_eventually — random 10% death works within 200 ticks.
– test_particle_heavier_gravity — gravity is measurably 0.06.
– test_particle_engine_random_death — all 64 particles die via random mechanism.
Backward compatibility (2):
– test_terrain_gen_still_works — terrain generation unaffected by bush removal.
– test_grass_still_ticks — grass tick behavior unaffected.
The backward compatibility tests are important. Removing bush could theoretically affect terrain generation (if bush was being placed during generation) or grass ticks (if grass spread checked for bush). It does not — bush was only placed by the player via key 5, and grass spread checks only for dirt — but verifying this explicitly prevents regression.
All 57 previous tests pass unchanged. The bush removal did not break anything because bush was well-isolated. The particle changes did not affect previous particle tests because those tests were updated to match the new behavior.
Running cargo test:
running 34 tests ... test result: ok. 34 passed
running 12 tests ... test result: ok. 12 passed
running 11 tests ... test result: ok. 11 passed
running 8 tests ... test result: ok. 8 passed
65 tests. All green.
Gap Analysis
| Feature | Status | Notes |
|---|---|---|
| Bush removal (tile, mesh, keybind) | DONE | Tile, chunk_mesh, main all updated |
| Particle random death | DONE | Age/lifetime replaced with 10% chance |
| Particle gravity 0.06 | DONE | Updated from 0.04 |
| Particle speed formula | DONE | 0.7x horizontal, 1.0x vertical |
| Crosshair +-8/9 | DONE | Renderer updated |
| Block preview scale 48 | DONE | Renderer updated |
| Key 5 = wood | DONE | Input handling updated |
| FULLSCREEN_MODE = true | DEFERRED | Inappropriate for dev build |
| Package rename back to rubydung | N/A | Cosmetic, no Rust equivalent |
| MinecraftApplet removal | N/A | Never ported |
| glAlphaFunc removal | N/A | Not applicable to wgpu |
| setupOrthoCamera extraction | N/A | Cosmetic refactor |
One item deferred. Four items not applicable. Everything else done.
The Lesson in Subtraction
This was the easiest port so far in terms of implementation time and the most instructive in terms of development philosophy.
Adding code is straightforward: you write it, test it, ship it. Removing code requires understanding what you built well enough to know which pieces can come out without collapsing the rest. The bush removal worked cleanly because the rd-20090515 architecture was clean: bush was a tile type with its own ID, its own rendering path, and its own behavior, all accessed through well-defined interfaces. Pull the ID, remove the match arms, delete the special case, and the rest of the system does not notice.
The allow-list vs. deny-list insight was the one non-obvious lesson. When a system is built around “everything has property X except these exceptions,” removing an exception requires re-evaluating whether the deny-list formulation is still appropriate. In our case, switching to an allow-list was the right call: it is more explicit, handles edge cases better, and will be easier to maintain as tiles are added and removed in future versions.
The particle tuning was a reminder that game development is not just engineering. The gravity and speed constants are not derived from physical laws or algorithmic requirements. They are aesthetic parameters, tuned by playing the game and adjusting until destruction feels right. Porting those parameters is trivial — copy the numbers — but understanding why they changed requires thinking about game feel, not just game logic.
We have 874 versions to go. The codebase is 175 lines shorter than yesterday. The world still has hills. Grass still spreads. Zombies still wander. But the bushes are gone, the crosshair is bigger, and the particles fall harder.
Sometimes progress means having less.