AI Super Mario World Part 2: Taking control and episode loops

Part 2 of 8: inject controller inputs from Lua, save and restore game state, and build the episode loop that all training will run through.

In Part 1 we read Mario’s position from memory and drew a HUD overlay. Now we’re going to make Lua press buttons, save the game state, and run Mario through the level automatically.

Mario Intro animation with custom HUD

By the end of this post your script will:

  1. Save Mario’s position at the start of Yoshi’s Island 2
  2. Run an episode where Mario walks to the right on his own
  3. Measure how far he got before dying
  4. Reset and do it again, printing results to the console

This episode loop is the foundation everything else in the series builds on.

Mario Intro animation with custom HUD Not exactly impressive, but the loop is working. Mario jumped, walked, died, and we measured his progress.

Starting point

We’re building on the script from Part 1. Open SuperMarioWorld_ai.lua. It should look like this:

-- smw_ai.lua  -  Part 1: complete script
print("script started")

event.onframeend(function()
    local marioX       = memory.read_s16_le(0x94, "WRAM")
    local marioY       = memory.read_s16_le(0x96, "WRAM")
    local isDead       = memory.read_u8(0x71,    "WRAM") == 0x09
    local xVelocity    = memory.read_s8(0x7B,    "WRAM")
    local cameraX      = memory.read_s16_le(0x1A, "WRAM")
    local powerUpState = memory.read_u8(0x19,    "WRAM")

    local bg = 0xAA000000
    gui.drawBox(0, 0, 120, 85, 0x00000000, bg)
    gui.text(8, 4,   string.format("X:    %d", marioX),        "white")
    gui.text(8, 20,  string.format("Y:    %d", marioY),        "white")
    gui.text(8, 36,  string.format("camX: %d", cameraX),       "white")
    gui.text(8, 52,  string.format("velX: %d", xVelocity),     "white")
    gui.text(8, 68,  string.format("pwr:  %d", powerUpState),  "white")
    gui.text(8, 104,
        isDead and "[ DEAD ]" or "[ alive ]",
        isDead and "red" or "lime")
end)

In Part 1, event.onframeend ran our code automatically every frame. For training, we need to drive the emulator ourselves: step one frame, check what happened, and decide what to do next.

We’re going to adapt this script step by step. First, we’ll restructure this code to add our control loop.

Step 1: Refactor Part 1 into helpers

In this step, we encapsulate the Part 1 behavior into two helper methods:

  • readState() reads WRAM values and returns them in one data object.
  • drawHud(st) draws the overlay from that data object.

We want one place for state-reading logic and one place for HUD-rendering logic. Replace your Part 1 callback with this refactor:

-- smw_ai.lua  -  Part 2: refactor helpers
print("script started")

local function readState()
    return {
        marioX       = memory.read_s16_le(0x94, "WRAM"),
        marioY       = memory.read_s16_le(0x96, "WRAM"),
        isDead       = memory.read_u8(0x71,    "WRAM") == 0x09,
        xVelocity    = memory.read_s8(0x7B,    "WRAM"),
        cameraX      = memory.read_s16_le(0x1A, "WRAM"),
        powerUpState = memory.read_u8(0x19,    "WRAM"),
    }
end

local function drawHud(st)
    local bg = 0xAA000000
    gui.drawBox(0, 0, 120, 85, 0x00000000, bg)
    gui.text(8, 4,   string.format("X:    %d", st.marioX),       "white")
    gui.text(8, 20,  string.format("Y:    %d", st.marioY),       "white")
    gui.text(8, 36,  string.format("camX: %d", st.cameraX),      "white")
    gui.text(8, 52,  string.format("velX: %d", st.xVelocity),    "white")
    gui.text(8, 68,  string.format("pwr:  %d", st.powerUpState), "white")
    gui.text(8, 104,
        st.isDead and "[ DEAD ]" or "[ alive ]",
        st.isDead and "red" or "lime")
end

event.onframeend(function()
    -- Clear the previous frame's HUD
    gui.clearGraphics()
    gui.cleartext()

    local st = readState()
    drawHud(st)
end)

NOTE: When you modify the LUA script, save your changes then click the Refresh button in BizHawk’s Lua Console; this will force BizHawk to reload your changes.

After you make these code modifications, you shouldn’t see any change in the emulator.

Try commenting out the drawHud(st) line and notice that the HUD disappears when you refresh the script. I added the two methods clearGraphics() and cleartext() to clear the HUD rendered from the previous frame. Try commenting out these lines as well, to see the difference.

Step 2: Replace onframeend with a manual frame loop

Now we switch from callback-driven updates to a loop we control with emu.frameadvance(). This is the key training pattern.

Delete the event.onframeend(function() ... end) block and replace it with:

while true do
    -- Clear the previous frame's HUD
    gui.clearGraphics()
    gui.cleartext()

    local st = readState()
    drawHud(st)
    emu.frameadvance()
end

Save and refresh. You should still see the same HUD updating every frame. The behavior is the same, but now we’re stepping the emulator frames ourselves.

Step 3: Add episode boundaries

To train reliably, we need to limit how long each training episode runs. We’ll add two constants to control this:

  • MAX_FRAMES: prevents creates a hard limit on the duration of the training session.
  • STUCK_TIMEOUT: gives up early if Mario doesn’t make progress, saving real world time if the training session gets stuck.

Add these constants under print("script started"):

print("script started")

local MAX_FRAMES    = 60 * 60  -- 60 seconds at 60fps
local STUCK_TIMEOUT = 60 * 5   -- give up if no forward progress for 5 seconds

At 60 fps, 60 * 60 is 3600 frames (60 seconds), and 300 stuck frames is 5 seconds.

Step 4: Add save state helpers

To measure fitness fairly, each episode needs to start from the same position. We add two helpers that save and reload the start state.

Add this below the constants:

local START_STATE = (os.getenv("USERPROFILE") or "C:/Users/Default") .. "/Documents/BizHawk/smw_start.state"
-- If BizHawk cannot find the or access this save file, replace the line above with a full path you control,
-- for example: local START_STATE = "c:/Users/YOUR_USERNAME/Documents/BizHawk/smw_start.state"

function saveStart()
    print("Using save state: " .. START_STATE)

    local ok, err = pcall(function()
        savestate.save(START_STATE)
    end)

    if ok then
        print("Saved start state.")
    else
        print("Save failed: " .. tostring(err))
    end
end

function loadStart()
    print("Loading save state: " .. START_STATE)

    local ok, err = pcall(function()
        savestate.load(START_STATE)
    end)

    if not ok then
        error("Could not load start state. Run saveStart() first. " .. tostring(err))
    end
end

Step 5: Create runEpisode skeleton

So far we’ve been running a simple manual loop. Now we’ll wrap that loop into a function so we can run multiple episodes and collect fitness scores. This function will be the core of our training loop.

Before we test runEpisode, we need to capture a starting position. Make sure Mario is standing at the very start of Yoshi’s Island 2, on the ground, with the level fully loaded (not mid-transition). In the Lua Console input at the bottom, type saveStart() and press Enter.

Screenshot: Lua Console with "Saved start state." visible in output You should see the confirmation message in the console output. If nothing appears, make sure the script is refreshed and running first.

Warning

If you get attempt to call a nil value (global 'saveStart'), your loaded script does not define saveStart in the global scope yet. Re-check Step 4 (it must be function saveStart(), not local function saveStart()), save the Lua file, click Refresh, and run saveStart() again.

Now add this function below drawHud:

local function runEpisode(buttonsFn)
    loadStart()

    local startX      = readState().marioX
    local maxX        = startX
    local stuckFrames = 0
    local deathFrames = 0

    -- we'll fill this loop in next
    for frame = 1, MAX_FRAMES do
        local st = readState()
        drawHud(st)
        emu.frameadvance()
    end

    return maxX - startX
end

This is an important function. This is our core training loop. It’s not complete and we’re going to spend a lot of time enhancing this function. For now, this code loads our starting state, initializes the observation variables, and then loops for the max number of frames.

Step 6: Move control into runEpisode

Now that we have a bounded episode function, we can remove the temporary infinite loop from Step 2. Control is now driven by calling runEpisode, not by a continuous loop.

Remove the temporary infinite loop from Step 2:

- while true do
-     local st = readState()
-     drawHud(st)
-     emu.frameadvance()
- end

Then add this temporary test call at the bottom:

local function idle(_state)
    return {}
end

local ok, err = pcall(function()
    local fitness = runEpisode(idle)
    print(string.format("Fitness: %d pixels", fitness))
end)

if not ok then
    print("Could not start episode: " .. tostring(err))
end

It won’t play well yet, but it runs one bounded episode and exits cleanly. At this stage it can run for up to about 60 seconds before returning, because stop conditions are added in the next step.

If you run the temporary test call above immediately after reloading the script, BizHawk will try to load the save state before it exists. Run saveStart() once first, then run the test call.

Step 7: Add progress, death, and stuck detection

The episode loop needs three stop conditions:

  • Death detection: If Mario dies for 3 frames straight, the episode ends.
  • Stuck detection: If Mario doesn’t advance for 300 frames, he’s stuck in an obstacle and the episode ends.
  • Progress tracking: We track the furthest X position so far; fitness is the distance traveled.

Inside runEpisode, update the frame loop:

    for frame = 1, MAX_FRAMES do
        local st = readState()

        if st.marioX > maxX then
            maxX = st.marioX
            stuckFrames = 0
        else
            stuckFrames = stuckFrames + 1
        end

        if st.isDead then
            deathFrames = deathFrames + 1
            if deathFrames >= 3 then break end
        else
            deathFrames = 0
        end

        if stuckFrames >= STUCK_TIMEOUT then break end

        local buttons = buttonsFn(st)
        joypad.set(buttons, 1)

        drawHud(st, frame, maxX, startX, stuckFrames)

        emu.frameadvance()
    end

Then update drawHud to accept those values and render all metrics in one place:

local function drawHud(st, frame, maxX, startX, stuckFrames)
    local bg = 0xAA000000
    gui.drawBox(0, 0, 120, 85, 0x00000000, bg)
    gui.text(8, 4,   string.format("X:    %d", st.marioX),       "white")
    gui.text(8, 20,  string.format("Y:    %d", st.marioY),       "white")
    gui.text(8, 36,  string.format("camX: %d", st.cameraX),      "white")
    gui.text(8, 52,  string.format("velX: %d", st.xVelocity),    "white")
    gui.text(8, 68,  string.format("pwr:  %d", st.powerUpState), "white")
    gui.text(8, 90, string.format("frame: %d / %d", frame, MAX_FRAMES), "white")
    gui.text(8, 106, string.format("maxX: %d", maxX), "white")
    gui.text(8, 122, string.format("fit: %d", maxX - startX), "lime")
    gui.text(8, 138, string.format("stuck: %d", stuckFrames), "yellow")
    gui.text(8, 154,
        st.isDead and "[ DEAD ]" or "[ alive ]",
        st.isDead and "red" or "lime")
end

Now runEpisode has real stop conditions and produces a meaningful fitness score.

Step 8: Inject inputs and test

Warning

If you did not run saveStart(), this step will fail because loadStart() has no state file to load. If you haven’t done it yet, run saveStart() now.

Replace the temporary idle test with:

local function sprintRight(_state)
    return { Right=true, B=true }  -- hold Right and B (run) every frame
end

local fitness = runEpisode(sprintRight)
print(string.format("Fitness: %d pixels", fitness))

Save and refresh. Mario should sprint right, fall into the first pit, and die. Then you’ll see the fitness score printed in the console.

Mario Intro animation with custom HUD Not exactly impressive, but the loop is working. Mario jumped, walked, died, and we measured his progress.

Step 9: Try multiple episodes and average

Run the same strategy multiple times to validate that the save state is working. Consistent scores across runs mean your start position and the simulation is locked in. If they vary, the save state wasn’t clean or we have a frame-stepping problem.

Replace the single-run call at the bottom with this:

local function evaluateStrategy(buttonsFn, numTrials)
    numTrials = numTrials or 3
    local total = 0
    for i = 1, numTrials do
        local result = runEpisode(buttonsFn)
        print(string.format("  Run %d: %d pixels", i, result))
        total = total + result
    end
    return total / numTrials
end

local avgFit = evaluateStrategy(sprintRight, 3)
print(string.format("Average: %.1f pixels", avgFit))

Save and refresh. You should see three individual scores followed by the average. If all three are the same (or very close), your save state is working correctly. If they vary a lot, go back and re-save the start state - timing matters.

script started
Loading save state: C:\Users\username/Documents/BizHawk/smw_start.state
Fitness: 1273 pixels
script started
Loading save state: C:\Users\username/Documents/BizHawk/smw_start.state
  Run 1: 1273 pixels
Loading save state: C:\Users\username/Documents/BizHawk/smw_start.state
  Run 2: 1273 pixels
Loading save state: C:\Users\username/Documents/BizHawk/smw_start.state
  Run 3: 1273 pixels
Average: 1273.0 pixels

Complete script for Part 2

Here’s everything in one place:

-- smw_ai.lua  -  Part 2: episode loop
-- smw_ai.lua  -  Part 2: episode loop
print("script started")

local START_STATE   = (os.getenv("USERPROFILE") or "C:/Users/Default") .. "/Documents/BizHawk/smw_start.state"
-- If BizHawk still cannot find the file, replace the line above with a full path you control using your Windows profile folder.
local MAX_FRAMES    = 60 * 60
local STUCK_TIMEOUT = 60 * 5

function saveStart()
    print("Using save state: " .. START_STATE)

    local ok, err = pcall(function()
        savestate.save(START_STATE)
    end)

    if ok then
        print("Saved start state.")
    else
        print("Save failed: " .. tostring(err))
    end
end

function loadStart()
    print("Loading save state: " .. START_STATE)

    local ok, err = pcall(function()
        savestate.load(START_STATE)
    end)

    if not ok then
        error("Could not load start state. Run saveStart() first. " .. tostring(err))
    end
end

local function readState()
    return {
        marioX       = memory.read_s16_le(0x94, "WRAM"),
        marioY       = memory.read_s16_le(0x96, "WRAM"),
        isDead       = memory.read_u8(0x71,    "WRAM") == 0x09,
        xVelocity    = memory.read_s8(0x7B,    "WRAM"),
        cameraX      = memory.read_s16_le(0x1A, "WRAM"),
        powerUpState = memory.read_u8(0x19,    "WRAM"),
    }
end

local function drawHud(st, frame, maxX, startX, stuckFrames)
    local bg = 0xAA000000
    gui.drawBox(0, 0, 120, 85, 0x00000000, bg)
    gui.text(8, 4,   string.format("X:    %d", st.marioX),       "white")
    gui.text(8, 20,  string.format("Y:    %d", st.marioY),       "white")
    gui.text(8, 36,  string.format("camX: %d", st.cameraX),      "white")
    gui.text(8, 52,  string.format("velX: %d", st.xVelocity),    "white")
    gui.text(8, 68,  string.format("pwr:  %d", st.powerUpState), "white")
    gui.text(8, 90, string.format("frame: %d / %d", frame, MAX_FRAMES), "white")
    gui.text(8, 106, string.format("maxX: %d", maxX), "white")
    gui.text(8, 122, string.format("fit: %d", maxX - startX), "lime")
    gui.text(8, 138, string.format("stuck: %d", stuckFrames), "yellow")
    gui.text(8, 154,
        st.isDead and "[ DEAD ]" or "[ alive ]",
        st.isDead and "red" or "lime")
end

local function runEpisode(buttonsFn)
    loadStart()
    local startX      = readState().marioX
    local maxX        = startX
    local stuckFrames = 0
    local deathFrames = 0

    for frame = 1, MAX_FRAMES do
        local st = readState()

        if st.marioX > maxX then
            maxX = st.marioX
            stuckFrames = 0
        else
            stuckFrames = stuckFrames + 1
        end

        if st.isDead then
            deathFrames = deathFrames + 1
            if deathFrames >= 3 then break end
        else
            deathFrames = 0
        end

        if stuckFrames >= STUCK_TIMEOUT then break end

        local buttons = buttonsFn(st)
        joypad.set(buttons, 1)

        drawHud(st, frame, maxX, startX, stuckFrames)

        emu.frameadvance()
    end

    return maxX - startX
end

local function evaluateStrategy(buttonsFn, numTrials)
    numTrials = numTrials or 3
    local total = 0
    for i = 1, numTrials do
        local result = runEpisode(buttonsFn)
        print(string.format("  Run %d: %d pixels", i, result))
        total = total + result
    end
    return total / numTrials
end

local function sprintRight(_state)
    return { Right=true, B=true }
end

local avgFit = evaluateStrategy(sprintRight, 3)
print(string.format("Average fitness: %.1f pixels", avgFit))

What to expect

  • Mario sprints right and dies when he walks into the red Kuppa, three times in a row
  • Three individual scores print, then an average
  • The scores should be consistent across runs

Next up

In Part 3 we’ll build the sensor grid: the 13x13 tile view centered on Mario that turns the game world into a neural network input vector.

A challenge for you: modify sprintRight to also press A (jump) every 30 frames. Does that improve the fitness score? What if Mario runs?

About Alex

Alex Eisenhart is a former teacher, former data analyst, former senior developer, former enterprise architect, now VP of Software Engineering at Westfalia Technologies. Wood worker, AI tinkerer, and aspiring author.

← All posts