Teaching programming with Lua: a Minetest mod

Since my daughter liked playing Minecraft, we thought of doing something like a mod, but that turned out to be hard and borderline illegal due to decompilation of Java bytecode involved. So instead, I started looking for an open-source alternative, found a few and decided to use Minetest that appeared to be quite like Minecraft, had large enough community, and mods in Lua with public API and plenty of examples.

We chose this project: a mod that is capable of generating a world out of a hand-drawn map, where height is represented by brightness, there’s water (painted blue) and grass (painted green). In the end, the main part of the program that my daughter actually wrote herself turned out to be less than 100 lines of Lua.

By that time, we had mastered loops and tables and the project seemed doable.

Algorithm

The algorithm is quite simple really: for each pixel of the original image, determine its color (blue or green) and brightness. Dividing brightness by 10 gives a nice max hills height of 25 blocks, so there should be a loop that places blocks of dirt from 1 to the calculated height, followed by a block chosen depending on the color of the corresponding pixel - water or grass.

We decided that water should always be at the “sea level”, so we ignore brightness of blue pixels and set it to be 2. Another minor complication is what to do when the color is neither green nor blue? We chose to place stone blocks in those places; in real examples, those appeared where we darkened the image too much to create high hills. This way, hilltops are sometimes rocky. Nice touch, come to think of it.

Helper functions

There’s quite a bit of explanation involved in determining color of a pixel. After all, we see it as green, while the program sees a tuple of numbers, usually different for even nearby green pixels. For instance, these are all greens: (113, 245, 80), (54, 173, 80), (83, 204, 128).

As an easily explainable solution (remember, this had to be parseable by an 8-year-old), I chose this solution:

local function is_grass(px)
    return px.G >= 150 and px.R < px.G and px.B < px.G
end

For blue color we came up with something very similar, just tweaked the constant a bit (see below for actual code).

Tweaking

After we did all the straight-forward things, my daughter asked if we can make sandy riverbanks. I was a bit reluctant at first, because it involved a lot of coding (by beginner’s standards) with lots of repetition, but enthusiasm won and we implemented a function that checks nearby pixels to see if any is blue and when it is, place sand block instead of the usual grass. Worked like a charm!

Demo

So the original watercolor-painted map turned to be looking like this:

Its’ surface is very uneven because the original was painted; computer-drawn maps where, unlike in the real world, you have to try hard to create noise, are, of course, a lot smoother.

Check out a live demo:

Code

This is meat of the mod that was written by my daughter; full code of the imagemap mod is also available, but it may differ from the code below because we continued to work on it and kept updating the github repo.

local function clean()
    math.randomseed(os.time())

    for x = 0, 100 do
        for z = 0, 100 do
            for y = 0, 30 do
                local pos = {x=x, y=y, z=z}
                minetest.remove_node(pos)
            end
        end
    end
end

local function is_grass(px)
    return px.G >= 150 and px.R < px.G and px.B < px.G
end

local function is_water(px)
    return px.G < px.B and px.R < px.B and px.B >= 100
end

local function get_height(px)
    if is_water(px) then
       return 2
    end

    local b = get_lum(px)
    return math.floor(b/10) + 3
end

local function near_water(img, z, x)
    if z > 1 then
    	local p = img:getPixel(z-1, x)
    	if is_water(p) then
            return true
    	end
    end
    
    if z < img.width then
    	local p = img:getPixel(z+1, x)
    	if is_water(p) then
            return true
    	end
    end
    
    if x > 1 then
    	local p = img:getPixel(z, x-1)
    	if is_water(p) then
            return true
    	end
    end
    
    if x < img.height  then
    	local p = img:getPixel(z, x+1)
    	if is_water(p) then
            return true
    	end
    end
       
    return false         
end

local function generate_blocks(img)
    clean()
    
    for x = 1, img.height do
        for z = 1, img.width do
            p = img:getPixel(z, x)
	       h = get_height(p)

	       for y = 1, h do
	           pos = {x = x, y = y, z = z}
                minetest.set_node(pos, blocks.dirt)   
	       end
	    
            if is_water(p) then
                b = blocks.water
            elseif is_grass(p) then
                b = blocks.grass
            else
                b = blocks.stone
            end
            
            if not is_water(p) and near_water(img, z, x) then
                b = blocks.sand
            end
            
            pos = {x = x, y = h+1, z = z}
            minetest.set_node(pos, b)
        end
    end
end

References

Maxim Kartashev

Maxim Kartashev
Pragmatic, software engineer. Working for Altium Tasking on compilers and tools for embedded systems.