DEV Community

loading...
Cover image for Creating the Scrolling Background Effect from Battletoads

Creating the Scrolling Background Effect from Battletoads

robotspacefish profile image Jess Updated on ・8 min read

Who doesn't love/hate the Turbo Tunnel level in Battletoads? I decided to analyze and recreate the scrolling background as seen in the gif below:

Battletoads (1991) Turbo Tunnel level

My crude re-creation in Pico-8. Check out that uncanny likeness. 😉

Here is a screenshot from the actual game. If you look closely you can see the edges of the sprites and where they are repeating. I indicate some spots with a rectangle and white arrows.

Alt Text

Here is the background I created showing how my sprites are repeating.

Note: Examples are written using Lua in the Pico-8 Fantasy Console. Functions include excerpts of code relevant to my explanation and aren't necessarily the complete code.

Bear with me as I explain my initial thought process for scrolling the background, before I deleted all that code and did something else.

First I decided to create separate arrays for each background section. The array would hold at least enough elements to cover the width of the screen. Each element would be an object that kept track of the width of the sprite, the x1 value (top left of the sprite), and the x2 value (top right of the sprite).

I did something like the code below on my first pass. first_row, second_row, etc are tables, which is the data structure in Lua used to create objects and arrays. In init_bg() I looped from 0 to 7 to create the tables for each sprite row. add(first_row, { x1 = i * 16, w = 16, x2 = i * 16 + 16 }) pushes the table of values to the first_row table. Since 7 * 16 = 112 that puts the last sprite at an x value of 112, and since the sprite is 16px that covers the 128px width of the game screen. The tables containing 32px wide sprites ends up creating more sprites than needed to cover the screen but it didn't really matter if there were a few extra offscreen. create_land() does a similar thing, but it loops 10x and creates 2 tables: one for the land top and another for the bottom.

function _init()
  -- speeds to control how fast each row is moving
  top_bg_speed = 5
  bottom_bg_speed = 6
  middle_bg_speed = 1
  land_speed = 5

  -- tables for each row of sprites
  first_row = {}
  second_row = {}
  land_top = {}
  land_bottom = {}
  third_row = {}
  bottom_row = {}

  init_bg()
end

function init_bg()
  for i = 0, 7 do
    add(first_row, create_spr_table(i, 16))

    add(second_row, create_spr_table(i, 32))

    add(third_row, create_spr_table(i, 32))

    add(bottom_row, create_spr_table(i, 16))
  end

  create_land()
end

function create_spr_table(i, w)
  return { x1 = i * w, w = w, x2 = i * w  + w }
end

function create_land()
  for i = 0, 10 do
    add(land_top, create_spr_table(i, 32))
    add(land_bottom, create_spr_table(i, 32))
  end
end

Enter fullscreen mode Exit fullscreen mode

In the _update() function that runs 30fps, I looped through each table to set the new x1 and x2 values. Each row has a speed variable that can be adjusted depending on how fast you want the sprites to scroll. In Battletoads, when you first get on the Turbo Bike the sprites in the foreground and background move at slightly different speeds and gradually ramp up to look like they're going at the same speed because you're going so fast it would be indistinguishable. In my .gif above you'll notice I kept mine at different speeds because I liked the effect, but I could easily make each section go at the same speed by changing the variable values in _init().

As each sprite moved to the left I needed to know when the sprite was offscreen so it could be removed from the table. I also needed to know the x2 value of the last sprite in each table so when I pushed a new sprite to the table I could use that x2 value for the new sprite's x1 value. The tables for the land sprites had to be handled differently than the rest because it doesn't endlessly repeat and needed to be started offscreen. Some rows are made up of sprites that belong together and are updated together using a loop for one of the tables.

function _update()
  -- only some rows are shown here, the rest are basically the same

  -- update first row values
  for i = 1, #first_row do
    first_row[i].x1 -= top_bg_speed
    set_new_x2(first_row, i)
  end

  -- update second/third row values
  for i = 1, #second_row do
    second_row[i].x1 -= middle_bg_speed
    third_row[i].x1 -= middle_bg_speed

    set_new_x2(second_row, i)
    set_new_x2(third_row, i)
  end

  -- update land values
  for i = 1, #land_top do
    land_top[i].x1 -= land_speed
    land_bottom[i].x1 -= land_speed

    set_new_x2(land_top, i)
    set_new_x2(land_bottom, i)
  end

  -- remove element once it goes out of view
    if (is_offscreen_left(first_row[1].x2)) del_first_value(first_row)

    if is_offscreen_left(second_row[1].x2) then
    del_first_value(second_row)
    del_first_value(third_row)
  end

  -- add element to end of table if there's an empty space between the last element x2 value and end of the game screen
  if (should_add_bg_spr(first_row[#first_row].x2)) add_bg_spr_to_end(first_row, 16 )

  if should_add_bg_spr(second_row[#second_row].x2) then
    add_bg_spr_to_end(second_row, 32)
    add_bg_spr_to_end(third_row, 32)
  end

  -- update land
    if (is_offscreen_left(land_top[#land_top].x2)) reset_land()
end

function reset_land()
  for i = 1, #land_top do
    local start = i * 32 + 128

    land_top[i].x1 = start
    land_top[i].x2 = land_top[i].x1 + 32

    land_bottom[i].x1 = start
    land_bottom[i].x2 = land_top[i].x1 + 32
  end
end

function add_bg_spr_to_end(tbl, w)
  local x1 = tbl[#tbl - 1].x2
  add(tbl, { x1 = x1, w = w, x2 = x1 + w })
end

function should_add_bg_spr(x)
  -- 148 for a smoother addition since it's out of view
  return x <= 148
end

function del_first_value(tbl)
  del(tbl, tbl[1])
end

function is_offscreen_left(x)
  return x <= 0
end

function set_new_x2(tbl, idx)
    tbl[idx].x2 = tbl[idx].x1 + tbl[idx].w
end
Enter fullscreen mode Exit fullscreen mode

In the _draw() function that also runs at 30fps, I looped through each table again to draw each sprite at the x1 values of each table element. There is a row in the middle that's static so there's no table for it. I just loop 8 times (0 - 7) to fill the screen with 8 static sprites each frame. Since the land has end pieces it is handled differently here as well. As I looped through the land tables, if the index was 1 (Lua indexes begin with 1, not 0) or the index equivalent to the length of the array, I set the sprites to the end pieces. If it was the last index I also set flp to true, which controls whether the sprite should be flipped on the x axis.

Note: In Pico-8 a sprite is drawn using spr(sprite_number, x, y, w, h, flip_x, flip_y) where w is the number of sprites wide and h is the number of sprites tall to draw. Each sprite is 8x8 so in order to draw a 16x16 image, you would set w=2 and h=2

function _draw()
    cls()

  -- draw top row
  for i = 1, #first_row do
    spr(64, first_row[i].x1, 0, 2, 2) -- first_row
  end

  -- draw second/third rows
  for i = 1, #second_row do
    spr(66, second_row[i].x1, 16, 4, 4) -- second row
    spr(70, third_row[i].x1, 48, 4, 2) -- third row
  end

  -- draw static middle row
  for i = 0, 7 do
    spr(96, i * 16, 64, 2, 2)
  end

  -- draw land
  draw_land()

  -- draw bottom row
  for i = 1, #bottom_row do
    spr(204, bottom_row[i].x1, 112, 2, 2)
  end
end

function draw_land()
  for i = 1, #land_top do
    local flp = false
    local top_spr = 196
    local bottom_spr = 200

    if i == 1 or i == #land_top then
      top_spr = 192
      bottom_spr = 232
    end

    if (i == #land_top) flp = true

    spr(top_spr, land_top[i].x1, 64, 4, 4, flp)
    spr(bottom_spr, land_bottom[i].x1, 96, 4, 2, flp)
  end
end
Enter fullscreen mode Exit fullscreen mode

This all created the end result perfectly. Then I figured why add/remove from the table when I can make a set amount (enough to cover the 128px width + at least 32px extra offscreen) and just change the x values as each sprite goes offscreen. Before I bothered to try that, it dawned on me that I actually don't need to do any of this. 🤦‍♀️ I don't need tables at all. I can just create everything in a single loop with a couple of variables that keep track of the x values for each row.

Let's Try This Again

I deleted all the tables and the functionality that went with them and created a few new variables: top_startX, middle_startX, bottom_startX, and land_startX. These values will control where each row begins. They are initialized to 0 and as the speeds are subtracted from each row every frame they will become negative until they reach -128; then they will be reset to 0. Well, except for land_startX which I'll explain in a bit.

The only thing _update() needs to do now is subtract the speeds from the starting x values for each row.

In _draw() there is a loop where each row is drawn, minus the land. Where the sprites are at least 32px wide I was able to just draw the sprite because it would create the sprites beyond the 128px screen width. For example, if i = 7, spr(66, i * 32 + middle_startX, 16, 4, 4) creates the sprite at an x value of 224 plus whatever middle_startX is equal to. As I explained above, it will be subtracted each frame, which is what makes the sprites look like they're scrolling to the left. Where the sprites were only 16x16 there wasn't any coverage for the right side as the sprites moved to the left before the next loop. I would have to loop to at least 16 (instead of 7) for full coverage, so instead I drew additional sprites at the x value + 128 (the screen width).

For the land, I had to create a land_endX variable that was equal to the length of the land + 1 multiplied by 32 (the width of the land section). Then I reset land_startX once it was <= -land_endX. Instead of resetting it to 128 I added a gap_start parameter so I can change the distance between each piece of land if I want to.

This is the full code below to create the scrolling effect. It's much simpler and cleaner than the previous method using tables and it works the same.

function _init()
  top_bg_speed = 5
  bottom_bg_speed = 6
  middle_bg_speed = 1
  land_speed = 5
  top_startX = 0
  middle_startX = 0
  bottom_startX = 0
  land_startX = 0
end

function _draw()
  cls()
  palt(0, false) -- make black visible
  palt(1, true) -- make darkblue transparent

  -- draw bg (minus land)
  for i = 0, 7 do
    -- top row 16x16
    spr(64, i * 16 + top_startX, 0, 2, 2)
    spr(64, i * 16 + top_startX + 128, 0, 2, 2)

    -- second row 32x32
    spr(66, i * 32 + middle_startX, 16, 4, 4)

    -- third row 32x16
    spr(70, i * 32 + middle_startX, 48, 4, 2)

    -- static middle row
    spr(96, i * 16, 64, 2, 2)

    -- bottom row 16x16
    spr(204, i * 16 + bottom_startX, 112, 2, 2)
    spr(204, i * 16 + bottom_startX + 128, 112, 2, 2)
  end

  -- draw land
  draw_land(20, 128)

  -- reset
  if (x_should_reset(top_startX)) top_startX = 0
  if (x_should_reset(middle_startX)) middle_startX = 0
  if (x_should_reset(bottom_startX)) bottom_startX = 0
end

function x_should_reset(x)
  return x <= -128
end

function _update()
  top_startX -= top_bg_speed
  middle_startX -= middle_bg_speed
  bottom_startX -= bottom_bg_speed
  land_startX -= land_speed
end

function draw_land(length, gap_start)
  local land_endX = (length + 1) * 32 -- +1 for off screen padding

  -- reset
  -- gap_start is what px you want the next piece of land to start
  if (land_startX <= -land_endX) land_startX = gap_start

  for i = 1, length do
    local flp = false
    local top_spr = 196
    local bottom_spr = 200

    if (i == 1 or i == length) then -- end piece
      top_spr = 192
      bottom_spr = 232
    end

    if (i == length) flp = true -- right end piece

    spr(top_spr, i * 32 + land_startX, 64, 4, 4, flp) -- top
    spr(bottom_spr, i * 32 + land_startX, 96, 4, 2, flp) -- bottom
  end
end
Enter fullscreen mode Exit fullscreen mode

Discussion (0)

Forem Open with the Forem app