Writing a platformer for the TIC-80 fantasy console

8 Bit Panda, a game for the TIC-80 fantasy console.

This is a post about how I wrote 8-bit panda, a simple classic-style platformer game for the TIC-80 fantasy console.

You can play the finished game here.

If you are a fan of retro gaming and you enjoy programming, chances are you have already come across the latest trend: fantasy consoles. If you haven’t, the two major ones you should look at are probably PICO-8 and TIC-80.

I went with TIC-80 because it’s free, is in active development, has a wider aspect ratio (240x136) than the PICO-8 and exports to many platforms like HTML, Android and desktop binaries.

In this article, I will describe how I wrote 8 Bit Panda, a simple platformer for TIC-80.

The Main Character

First of all, I needed a main character. I didn’t really put that much thought into it: the design process was very much just the question “why not a panda?”, to which the answer was “of course, why not.” So I set out to draw what would be my very first sprite using the TIC-80 sprite editor:

As I pause for a moment to let you fully take in my impressive lack of artistic skills, consider this: there are only 2²⁵⁶ possible 16-color 8x8 sprites. Only a few of those are pandas. If you can concede that this is not the worst possible one, I consider myself flattered.

Using that as a base, I drew several other sprites representing the main character in other poses: walking, jumping, attacking, etc.

Now that I’ve definitely lost all the readers who came here expecting lessons on how to draw pandas, let’s get into the code.

If you find it useful, you can follow along with the source code. But you don’t have to, since I will try to give as much context as possible on each topic. We’re not going to go through the entire source code, of course, I’ll just point out interesting parts along the way.

Building Levels

TIC-80 has a built-in map editor that you can (and should) use to make your levels. Using the map editor is pretty easy: it’s just a big matrix of tiles, each of which can be any one of the 256 sprites in the lower half of sprite sheet (the upper half, indices 256 to 511, can be drawn at runtime but can't be on the map, as they require 9 bits to represent).

Sprite vs. tile: in TIC-80, “sprite” just means one of the 512 predefined 8x8 images in the cartridge. Map tiles are just sprites (each map tile can be one of the 256 sprites in the lower half of the sprite sheet). So we say “sprite” when we want to refer to the graphic element, and we say “tile” when we’re referring to a cell on the map, even though the cell technically contains a sprite. Bottom line: it doesn’t matter, tiles and sprites are the same thing.

Using the map editor, I came up with a pretty simple “level” to start:

The first thing we need to observe is that there are two major tile types:

  • Solid tiles (dirt and dirt+grass tiles), which the player can stand on and will block the player’s movement.
  • Decorative tiles (trees, grass, lantern, stone, etc). These are just for aesthetics, and have no effect in the game.

Later on we will introduce entity tiles, but let’s not worry about that for now. In the code, I need to have some way to tell if a tile is solid or not. I opted for a simple approach and decided on a cut-off sprite index (80): if the sprite index is < 80, the tile is solid. If it’s ≥ 80, then it’s decorative. So in the sprite sheet, I just drew all the solid tiles before index 80, and all the decorative ones after 80:

But wait a minute, water is not solid! What is it doing in the solid section? Well, I didn’t tell you about overrides: there’s a list of tile solidity overrides that can replace the default solidity, telling us, for example that the water tile is actually not solid even though its position on the sprite sheet would normally make it solid. But it’s not decoration either, because it has an effect in the game.

Player State

If there’s one thing I learned in my programming career is that global variables are bad, but they are okay as long as you call them something fancy like a “singleton”. So I defined a few “singletons” to represent the state of the game. I use the term loosely because it's not OOP, they are more like top level structs than actual singletons.

Anyway, it doesn't matter what they are called. Let’s start with Plr, which represents the state of the player at any given time:

Plr={
lives=3,
x=0,y=0,
grounded=false,
...
}

It has many, many other fields but the most important aspect to note is that this object stores the entire state of the player in the current level: where the player is in the level, if they’re in the middle of a jump, standing on solid ground, swimming, resurfacing, dying, flying a plane (yep, it’s one of those pandas that flies planes), what’s the score, which powerups are active, etc.

Then there is the game state, which is different from player state. For example,

Game={
m=M.BOOT, -- current mode
lvlNo=0, -- level we're currently playing
...
}

It stores things like what’s the current mode (we’ll get into that), what is the current level, plus some game-wide data that’s computed at runtime.

It’s useful to separate game state from player state because then it’s pretty easy to start/restart levels: all you have to do is reset and wipe the player state and not the game state.

Rendering the level and the player

It’s incredibly easy to render the level and the player in TIC-80. All you really have to do is call map() to draw (a piece of) the map and spr() to paint sprites wherever you want. Since I drew my level starting at the top-left corner of the map, I can simply draw it like this:

COLS=30
ROWS=17
function Render()
map(0,0,COLS,ROWS)
end

Then I add the player:

PLAYER_SPRITE=257
spr(PLAYER_SPRITE, Plr.x, Plr.y)

Which gets us this:

And, of course, the panda just stays there at the corner being a panda and doing nothing. Not much of a game yet, but we’re getting there.

Of course, things get more complicated once you want to implement the side-scroller effect where the camera accompanies the player as the player moves around. The way I did this was to have a variable called Game.scr, which indicates how far the screen has scrolled right. So when drawing the map, I translate the map left by that many pixels, and when drawing anything, I always subtract Game.scr to draw it at the right place on the screen, something like:

spr(S.PLR.STAND, Plr.x - Game.scr, Plr.y)

Also, for efficiency, I also determine what part of the level is visible at any point, and only draw that rectangle of the map on the screen, instead of drawing it all. You can find the ugly details in the RendMap() function.

Next, we have to write the logic that moves the panda around in response to the player.

Move that panda

I never thought I’d write an article with this particular phrase as a sub-header, but life is full of surprises. The panda is our main character, and this is platformer game that’s all about moving and jumping, so one could arguably say that “moving the panda” is really the core of this game.

The “moving” part is pretty easy: you just modify Plr.x and Plr.y and the panda will appear at a different position. So the most basic implementation of movement we could have would be something like:

if btn(2) then
Plr.x = Plr.x - 1
elseif btn(3) then
Plr.x = Plr.x + 1
end

Remember btn(2) is the left key and btn(3) is the right key in TIC-80. But that would only move horizontally, and wouldn’t collide with things. We need something more elaborate, that takes gravity and obstacles into account.

function UpdatePlr()
if not IsOnGround() then
-- fall
Plr.y = Plr.y + 1
end
if btn(2) then
Plr.x = Plr.x - 1
elseif btn(3) then
Plr.x = Plr.x + 1
end
end

Presuming we implement IsOnGround() correctly, this will be a dramatic improvement: the player will move left and right, and will automatically fall as long as they are not on solid ground. So with this you can already walk around, and also fall off the cliff. Exciting!

But that wouldn’t take obstacles into account: what happens if you try to walk (horizontally) into a solid tile that’s in the way? You shouldn’t be able to move there. So, in general, we notice that a pattern starts to appear: there are two steps to movement:

  1. Decide where the player wants to move (including external factors like gravity).
  2. Decide whether the player is allowed to move there (because of obstacles).

The concept of “wants to move” is defined broadly and encompasses voluntary and involuntary displacement: when standing on solid ground, the player “wants” to move down (because of gravity) but isn’t allowed to, because moving down would collide with the ground.

It makes sense, therefore, for us to write a function that encodes the entire logic of “is the player allowed to move to a given x,y position”. But we will also need this when implementing enemies, because we will also have to ask “can this enemy move to position x,y?”. So, to generalize, it’s best to write a function that takes an x,y and an arbitrary collision rectangle (that way we can pass the correct x,y and collision rectangle for the player or enemy entity as appropriate):

C=8  -- constant for tile size (always 8 in TIC-80)
-- Check if an entity with collision rectangle
-- cr={x,y,w,h} can move to position x,y
function CanMove(x,y,cr)
local x1 = x + cr.x
local y1 = y + cr.y
local x2 = x1 + cr.w -1
local y2 = y1 + cr.h - 1
-- check all tiles touched by the rect
local startC = x1 // C
local endC = x2 // C
local startR = y1 // C
local endR = y2 // C
for c = startC, endC do
for r = startR, endR do
if IsTileSolid(mget(c, r)) then return false end
end
end
end

The logic is straightforward: just find the boundaries of the rectangle and iterate over all the tiles touched by the rectangle and check if there’s a solid one among them (IsTileSolid() just performs our “≥ 80” test, plus overrides). If we don’t find a solid tile that’s in the way, we return true, meaning “ok, you can move there”. If we find one, we return false, meaning “no, you can’t move there.” The two situations are illustrated below.

Let’s write another convenience function that tries to move by a certain displacement if allowed:

PLAYER_CR = {x=2,y=2,w=5,h=5}
function TryMoveBy(dx,dy)
if CanMoveEx(Plr.x + dx, Plr.y + dy, PLAYER_CR) then
Plr.x = Plr.x + dx
Plr.y = Plr.y + dy
return true
end
return false
end

Now our implementation of the move function is much cleaner: first decide where we want to go, then check if we are allowed. If we are, then move there.

function UpdatePlr()
-- being "on the ground" means "we can't move down"
Plr.grounded = not CanMove(Plr.x, Ply.y + 1)
if not Plr.grounded then
-- if not on ground, fall.
Plr.y = Plr.y + 1
end
local dx = btn(2) and -1 or (btn(3) and 1 or 0)
local dy = 0 -- we will implement jump later
TryMoveBy(dx,dy)
end

There we go, we have obstacle-aware movement. Later on when we add solid entities (moving platforms and the like) we will have to complicate this a bit to also check for collisions against entities, but the principle is the same.

Panda animations

If we always use that same sprite (#257), the game will be boring because the panda will always be in that standing pose. Instead, we want the panda to walk/jump/attack, etc. So we have to vary the sprite based on the state of the player. To make it easier to refer to sprite numbers, let’s declare some constants:

-- S is a table with all the sprites
S={
-- S.PLR is a table with only the player sprites
PLR={
STAND=257,
WALK1=258, WALK2=259, JUMP=273, SWING=276,
SWING_C=260, HIT=277, HIT_C=278, DIE=274,
SWIM1=267, SWIM2=268,
}
}

These corresponds to our several panda sprites in the spritesheet:

So in our rendering function we decide which sprite we will use. This is the RendPlr() function, where you will find such things as:

local spid
if Plr.grounded then
if btn(2) or btn(3) then
spid = S.PLR.WALK1 + time()%2
else
spid = S.PLR.STAND
end
else
spid = S.PLR.JUMP
end
...
spr(spid, Plr.x, Plr.y)

Which essentially mean: if the player is on solid ground and walking, then do the walk animation alternating sprites S.PLR.WALK1 and S.PLR.WALK2. Otherwise, if on solid ground and not walking, use S.PLR.STAND. If not on solid ground (falling or jumping), use S.PLR.JUMP.

There’s additional logic for figuring out which way the player is facing and doing animation sequences like the attack sequence or the jump sequence, and for adding some sprite overlays to represent powerups.

Jumping

Humans have strange expectations: when we jump in real life, there’s actually very little we can do to alter our trajectory mid-jump, but when we play platformer games, we expect (in fact, we demand) that the character be able to make arbitrary changes to their jump trajectory in mid-air. So, like so many other game protagonists, our panda will have the physics-defying ability to move freely in mid-air as well.

This actually makes it much simpler to implement jumps. A jump is essentially a sequence of changes to the player’s Y coordinate. The X coordinate is freely controlled by the arrow keys just as if the player were on the ground.

So we represent the jump an iteration through a “jump sequence”:

JUMP_DY={-3,-3,-3,-3,-2,-2,-2,-2,1,1,0,0,0,0,0}

As the player jumps, their Y position is changed by the amounts shown in the sequence, on each frame. The variable that keeps track of where we are in the jump sequence is Plr.jmp.

The logic to start/end the jump is, approximately:

  • If on solid ground and pressing jump button (btn(4)), start the jump (set Plr.jmp=1).
  • If a jump is in progress (Plr.jmp>0) then proceed with the jump, attempting to change the player’s Y position by JUMP_DY[Plr.jmp], if allowed (as per the CanMove() function).
  • If at some point in the jump the player’s movement is hampered (CanMove() returns false), then interrupt the jump (set Plr.jmp=0 and start falling).

The resulting jump trajectory is far from being a perfect parabola, but is good enough for our purposes. The fall after the jump is a straight line because we don’t implement acceleration on the way down. I tried to, and it felt weird, so I left it unaccelerated. Also, having a 1 pixel/tick descent speed allows us to do some hacky tricks on collision detection.

Jump trajectory.

Entities

Tiles are nice, but they are static. There’s only so much excitement that can be had from jumping over inanimate blocks of dirt. To make our platformer come to life, we need some some enemies, powerups, etc. All these “things that move or interact” are called entities.

To start, I drew some terrifying enemies. They are mostly terrifying because of the quality of the drawing, not because they are scary in any way:

I just added those to the sprite sheet and made animations for each. I also established a new cut-off point: sprites whose index is ≥ 128 represent entities, not static tiles. So I can just add the enemies to my level like this in the map editor, and I will know they are enemies because of their sprite index:

Likewise, many other things are entities: chests, crumbling blocks, time-based platforms, elevators, portals, etc.

When loading the level, I check each tile on the map. If it’s ≥ 128, I delete that map tile and create an entity in its place. The ID of the entity (EID) indicates what it is. What do we use as EID? Easy: I just reuse the sprite number! So if the green slime enemy is sprite #180, then the EID for the green slime is simply 180. Easy.

All the entities are stored in the global Ents structure.

Entity Animations

Entities can be animated. Instead of hard-coding animations for each specific entity type, I just have a big table of animations indexed by EID, which indicates which sprites to cycle:

-- animation cycle for each EID.
ANIM={
[EID.EN.SLIME]={S.EN.SLIME,295},
[EID.EN.DEMON]={S.EN.DEMON,292},
[EID.EN.BAT]={S.EN.BAT,296},
[EID.FIREBALL]={S.FIREBALL,294},
[EID.FOOD.LEAF]={S.FOOD.LEAF,288,289},
[EID.PFIRE]={S.PFIRE,264},
...
}

Note that some of them are symbolic constants (like S.EN.DEMON) when they also coincide with the sprite for the entity, and some are hard-coded integers (292) because that second one is just a secondary frame in the animation that we never need to reference elsewhere.

When rendering, we can simply look up the correct animation on this table and render the correct sprite for each entity.

Meta Tags: Map Annotations

Sometimes we need to add some annotation to the map that will be used at run time. For example, if there’s a treasure chest, we need some way to represent what’s inside of it, and how many of it are there. For these cases, we use map annotation markers, special tiles with numbers 0–12 which are never actually displayed (they are removed from the map at run time):

When the level loader sees a chest, it looks above the chest to know what the contents of the chest are, and looks for the special numeric marker indicating how many of the item to spawn. So when the player hits the chest, all the items get spawned:

Meta tags also help represent, for example, where elevators have to start and stop, what the player’s start position in the level is, phase information for timed platforms, among other things.

They are also used for level compression. We will talk about that next.

Levels

The game has 17 levels. Where are they stored? Well, if you look at the map memory, here is what you will see:

TIC-80 has 64 map “pages”, each being one “screen” of content (30x17 tiles). The pages are numbered 0 to 63.

In our layout, we reserved the top 8 for run time use. That’s where we store the level after we unpack it (more about this soon). Then, each level is a sequence of 2 or 3 pages in the map memory. We also have pages for the tutorial screen, the win screen, the world map and the title screen. Here’s an annotated version of the map:

If you play the game, you might notice that the levels are actually much longer that what would fit in 2 or 3 screens. But in the cartridge’s map memory, they are much smaller. What is happening here?

You guessed it (and I spoiled it, too): levels are compressed! In map memory, each column comes with a meta tag at the top row that indicates how many times that column is repeated. In other words, we implement a simple form of RLE compression:

So when this page gets uncompressed, it actually represents a much longer section of the level (almost twice as large, sometimes larger on some levels).

This is what we use the top 8 map pages for at run time: when we’re about to play a level, we uncompress it onto pages 0–7 for gameplay. So this is the logic to start a level:

  1. Read the packed level from the correct place in the map memory.
  2. Uncompress it to the top 8 pages, according to the repeat count in each column in the packed level.
  3. Look for entities (sprites ≥ 128) and instantiate them.
  4. Look for the player start position (meta marker “A”) and put player there.
  5. Start playing.

Entity Behaviors

What causes an enemy to behave in a particular way? Take the red demon in the game, for example. Where does its innate desire to hurl fireballs at the player come from? Why can’t they just be friends?

Every entity behaves differently. Demons throw fireballs and jump. Green slimes just wander back and forth. Blue monsters jump periodically. Icicles fall when the player is close enough. Crumbling blocks crumble. Elevators elevate. Chests just sit there being chests, until they are hit by the player, at which point they open and spawn their contents.

An easy way to write all this logic would be:

if enemy.eid == EID.DEMON then
ThrowFireballAtPlayer()
elseif enemy.eid == EID_SLIME then
-- do something else
elseif enemy.eid == EID_ELEVATOR then
-- do something else
-- ...and so on, for every case...

That’s easy at first. But then things start to get complicated and repetitive. Take movement, for example: when an enemy moves, we have to do a bunch of checks to see if the enemy can move to the target position, we have to check whether or not it will fall, etc. But some enemies don’t fall, they fly (bats, for example). And some swim (fish). Some enemies want to face the player, some don’t want to. Some enemies want to follow the player. Some enemies just carry on, blissfully unaware about where the player is. What about projectiles? Fireballs, plasma balls, snow balls. Some are affected by gravity, some aren’t. Some collide with solid objects, some don’t. What happens when each of those hit the player? What happens when the player hits them? So many variables and combinations!

Writing if/else blocks for each of these cases starts to get cumbersome after a while. So instead of doing that, we have a behavior system, which is a pretty common and useful pattern in game development. First, we define all possible behaviors that something can have, and the necessary parameters. For example, it can:

  • Move (How much? How does it handle cliff edges? What happens when it hits something solid?)
  • Fall
  • Change directions (How often? Always face player?)
  • Jump (How often? How tall to jump?)
  • Shoot (Shoot what? In which direction? Aim at player?)
  • Be vulnerable (takes damage from player).
  • Hurt player on collision
  • Crumble on collision (How long?)
  • Auto destroy after a given time (How long?)
  • Grant a powerup (Which powerup?)
  • …etc…

After we define all the possible behaviors, we just assign them with the right parameters to the right entities, in what we call the EBT (Entity-Behavior Table). Here’s the entry for the red demon, for example:

-- Entity-Behavior Table
EBT={
...
[EID.EN.DEMON]={
-- Behaviors:
beh={BE.JUMP,BE.FALL,BE.SHOOT,
BE.HURT,BE.FACEPLR,BE.VULN},
  -- Behavior parameters:
data={hp=1,moveDen=5,clr=7,
aim=AIM.HORIZ,
shootEid=EID.FIREBALL,
shootSpr=S.EN.DEMON_THROW,
lootp=60,
loot={EID.FOOD.C,EID.FOOD.D}},
},
...
}

This says that demons have these behaviors: jump, fall, shoot, hurt, face player, vulnerable. Also, the parameters indicate that it has 1 hit point, moves every 5 ticks, has a base color of 7 (red), shoots fireballs (EID.FIREBALL), aims horizontally at the player (AIM.HORIZ), has a 60% loot drop probability, can drop foods C and D (sushi). Note how we can define the entire behavior of that enemy in just a few lines just by combining different behaviors!

What about those background mountains?

Ah, you noticed the mountains in the background! If you look in map memory, they are nowhere to be found. They also don’t move exactly with the player: they have a parallax effect, moving more slowly than the foreground to give the impression that they are far away in the background.

How is this effect accomplished? We actually generate the mountains at run time. When loading a level, we randomly (but with a fixed random seed) generate an elevation map with one value per cell, and make it a bit bigger than the level (about 300 values or so in total).

When we render the scene, we use the player’s position to figure out a displacement for the elevation map (dividing it by a constant in order to get the parallax effect), and then render the mountains using the mountain piece sprites in the sprite sheet. This is fast because we only render the mountains that are visible at that particular moment (which is easy to determine by looking at Game.scr, and doing some math).

Palette Overrides

One of the great features in TIC-80 is that it offers the ability to override the palette by poking some memory addresses in RAM. This means that each of our levels can have a “palette override” that we set when starting:

function SetPal(overrides)
for c=0,15 do
local clr=PAL[c]
if overrides and overrides[c] then
clr=overrides[c]
end
poke(0x3fc0+c*3+0,(clr>>16)&255)
poke(0x3fc0+c*3+1,(clr>>8)&255)
poke(0x3fc0+c*3+2,clr&255)
end
end

The RAM address 0x3fc0 is the start of the area where TIC-80 keeps its palette, so we just have to write bytes to that memory area to change the palette.

The World Map

The world map was a late addition to the game, and serves to orient the player as to their progress. It shows the 17 levels of the game distributed among the 6 islands:

The player can move around with the arrow keys and enter a level by pressing Z. The world map is implemented in two map pages: the foreground and background pages.

The background page (page #62 in map memory) just contains the static portions of the map:

At run time, it’s overlaid with the foreground page (page #61):

This page indicates where the levels are. The “1”, ‘2" and “3” tiles are the levels, and the game knows which island they belong to by looking around the tile for a meta marker (1–6). So, for example, when the game looks at the “2” tile on island 3 (right side of map), it notices that there is a “3” meta marker adjacent to it, so it knows that it’s level 3–2.

The “A” marker indicates the player’s start position, and the “B” marker indicate the start position of each island when restoring a saved game.

Sound Effects and Music

I composed all the sound effects and music using TIC-80’s built-in editor. For the sound effects, I wish I could tell you I have a good method for creating them, but I actually just clicked buttons and tweaked the numbers randomly until I got what I wanted, which I assume is a reasonable way of creating retro sound effects.

For background music, I actually read up a little bit on how to compose music, because I hadn’t done any composition before. Turns out that with a very rudimentary knowledge you can already put together something that sounds acceptable (or at least as acceptable to the ear as my terrible panda graphics are to the eye).

I composed 8 tracks (the TIC-80 limit) for this game:

  1. Level music A, used in islands 1–5.
  2. Level music B, used in islands 1–5.
  3. End of level chime (short).
  4. Level music C, used in islands 1–5.
  5. World map music.
  6. Title screen theme.
  7. Level music for Island 6 (final 2 levels)
  8. End of game theme (“The End” screen)

Conclusion

Writing this game was a tremendous amount of fun, and I’m deeply thankful to the creators of TIC-80 (Vadim Grigoruk) for coming up with this great platform. I hope you enjoy playing the game (and hacking with the source code too, if you want!) as much as I enjoyed making it!

I’m looking forward to writing more games for the TIC-80 and similar consoles in the future.