Implementing Infinite Scroll with Level Generation in Pico8

Demo Implementation

2022/04/24/infinite-scroll-demo-1.gif

The Pico 8 Map

The Pico-8 map is 128 cells wide and 64 cells tall. Only the first 32 cells from the top are reserved solely for the map; the remaining 128x32 cells share memory with the sprite sheet.

Each map cell is 8x8 pixels. This means the reserved map space is 1024x256 pixels.

Each map cell in the map space can be given a sprite ID designation. This ID is the ID of the sprite in sprite sheet, which is edited separately from the map sheet. The map sheet is just a mapping of references of sprites from the sprite sheet.

The Pico 8 viewport size is 16x16 cells. This means the usable map space can be divided up into 16 ‘screens’. More details on this are provided in the viewport section.

2022/04/24/pico-8-map.png

Pico 8 Sprites

The sprite sheet is managed and stored similarly to the map sheet (though there is some shared storage between the sprite and map sheets, which is why the bottom half of the map view is generally not used for level maps in sprite-heavy games).

There are two types of sprite rendering. The first is a sprite created by code and held in a variable. This sprite can be given custom properties and manipulated on the screen.

The second type of sprite is a map sprite. This sprite can be assigned to a cell on the map sheet by using the ‘draw’ tool in map edit mode. These sprites have static map positions, and cannot be moved via x,y positioning logic, but the contents of each map cell can be read or replaced with different sprite data. Built-in functions also allow reading bit state for managing interactions such as collisions with player sprites.

The Pico 8 Viewport

The Pico 8 viewport size is 16x16 cells (128x128 pixels). This means at any given time, 16 cells across and 16 cells high are visible. This corresponds to the camera() invocation, which sets the camera at the default position of 0,0. The camera shows 16x16 cells.

Sections of the map can be drawn onto the viewport using the map function. Any number of map functions can be invoked each frame to layer different sprite configurations.

Map API: map( celx, cely, sx, sy, celw, celh, [layer] )

  • celx, cely: the start position of the map sheet to draw from (in cells). 0,0 is the top left corner of the sprite sheet.
  • sx, sy: the screen position to draw to, in pixels (not cells). 16,16 would correspond to cell position 1,1 on the viewport screen.
  • celw, celh: the width and height of the map sheet to draw (in cells). This will draw the map sheet into memory, but only the current position as designated by the camera will be visible.
  • [layer]: optional, default is all layers; a list of layers to draw; this is a bit mask that filters against the flags that can be set on each sprite. This allows individual layer manipulation using the same map space.

Here is a simple map invocation that will draw the first 16x16 section of the map sheet:

map(0, 0, 0, 0, 16, 16)
  • celx = 0, cely = 0: start at the top left of the map sheet
  • sx = 0, sy = 0: pin the drawn sprite range to the top left of the viewport
  • celw = 16, celw = 16: draw the first 16x16 cells from the map sheet

If the camera stays locked to the same view as the map is drawn to, the visible sprites will remain in the same position on the screen. If the camera is moved, they will in effect move off the screen, but will remain in their current positions. The camera will simply be showing a different range of the map sheet.

Some examples of effects that be achieved with this technique are:

  • a background that scrolls at a different relative speed than the foreground
  • text sprites layered on top of both the background and foreground
  • hidden objects such as doors that could be revealed by removing the corresponding sprite from the fore layer

Base platformer implementation details

The player sprite has the ability to move backward and forward, and to jump up to 3 blocks high. Gravity is applied to the player sprite until it contacts a solid tile beneath it.

If the player falls below the viewport (y > 127, or 16 or more sprites below the top of the game), a life is lost and the player’s position is reset to the center of the screen.

The camera stays locked to the player’s horizontal position. This moves the viewport with the player.

The first 40x16 cells of the map sheet are drawn into the game space.

map(0, 0, 0, 0, 40, 16)

Sprite drawing begins at map sheet position 0,0 (cells) and, at the game space coordinates 0,0 (pixels), the next 40x16 (cells) map sprites (sprites 0-39 horizontally, 0-15 vertically) are drawn.

40 sprites is equivalent to 2.5 16-sprite-wide screens. This will be important for wrapping later to achieve the infinite scrolling effect.

Implementation of a randomized side-scrolling map

function replace_map_sprites()
    for y = 0,15 do
        back_pos = flr(player1.x / 8) - 10
        front_pos = flr(player1.x / 8) + 16
        mset(back_pos, y, 0)
        mset(front_pos, y, 0)
        -- 20% chance to place block
        if (rnd(100) < 20) mset(front_pos, y, 50)
    end
end

The above code effectively continually replaces two off-screen columns of sprites at x-positions relative to the player’s current position.

The function mset is a built-in function that sets the given map cell position with the given sprite ID.

Code breakdown

Execute 16 times, starting at y-position 0, and ending at y-position 15:

for y = 0,15 do

Determine the column position 10 cells behind the player (2 cells off-screen):

back_pos = flr(player1.x / 8) - 10

Determine the column position 16 cells in front of the player (8 cells off-screen; this is to prevent the player from moving back and forth to generate a more favorable configuration):

front_pos = flr(player1.x / 8) + 16

Set the x back_pos and front_pos cells at the current y-position to sprite 0, which is a placeholder for ’no sprite’ (this effectively deletes the map sprite at those positions):

mset(back_pos, y, 0)
mset(front_pos, y, 0)

Generate a random number between 0 and 100: rnd(100)

Then check if it’s less than 20, and if so, replace the front_pos cell at the current y-position with the cell id 50, which is a solid grey block: mset(front_pos, y, 50)

This effectively gives cells in the forward position a 20% chance to be solid blocks.

if (rnd(100) < 20) mset(front_pos, y, 50)

This is enough for random generation of a scattered solid block formation. It can be enhanced with further msets to enforce other characteristics of the terrain.

Implementation of map wrapping to achieve infinite scroll

The final piece for infinite scroll is to implement a player position wrapping.

This is achieved by jumping the player to the start of the drawn map range, and by copying the current map at player position at time of player position jump to the starting position screen of the drawn map range.

40 total map cells are drawn, which gives 1.5 screens off-screen in the forward direction at start time, providing enough space for a full-screen copy along with the forward 8 cell randomization.

The player jump point is at x > 256, or x cell 32. When the player is at cell 32, cells 25-40 are visible. These cells are copied to cells 0-15,0-15, then the player is moved to cell 8 (position x=64), and cells 0-15,0-15 are visible.

These values were selected due to the nature of the above randomization. These could be altered to achieve different wrap points depending on design needs.

Wrapping implementation code breakdown

function wrap_map()
    -- replace map cells 0 through 15 with current view
    if player1.x > 256 then
        for y = 0,15 do
            for x = 0,15 do
                -- the -8 is to shift from center of view, the player's x pos
                target_sprite = mget((player1.x / 8) + x - 8, y)
                mset(x, y, target_sprite)
            end
        end
        player1.x = 64
    end
end

When the player passes x-position 256 (cell 32):

if player1.x > 256 then

For each cell in the viewport’s dimensions of 16x16:

for y = 0,15 do
    for x = 0,15 do

Get the target sprite ID based on the player’s position and the current x and y iterations (note: player position is in pixels, but mget requires a cell position, which represents 8 pixels):

target_sprite = mget((player1.x / 8) + x - 8, y)

Set the initial map’s sprite cell with the target sprite:

mset(x, y, target_sprite)

Jump the player’s position back to the starting map cell range (this also jumps the cam since the cam is locked to the player’s position in this implementation).

player1.x = 64

Notes for alternate approach using sprites for infinite scroll

I chose to use the sprite map in this implementation because my code already utilizes the mget and fget (gets flags on target map cell) functions extensively to handle collisions and pickups. However, using instantiated sprite objects would work as well; they’d just need to be tracked in code and aligned with the player’s position, which would be somewhat more complex and would involve different collision implementations.