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:
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
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
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
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
Top comments (0)