
Creating a platformer for the TIC-80 virtual console
- Transfer

8 Bit Panda, a game for the fictional console TIC-80.
This post is about how I wrote 8-bit panda, a simple classic-style platformer for the fictional TIC-80 console .
You can play the finished game here .
If you are a fan of retro games and you like programming, then chances are that you are already familiar with the latest trend: fictional consoles. If not, then it is worth looking at their most famous representatives: PICO-8 and TIC-80 .
I chose TIC-80 because it is free and actively developed, has a wider aspect ratio (240x136) than PICO-8, and can export to many platforms, including HTML, Android and binary files for PC.
In this article I will tell you how I wrote for the TIC-80 a simple 8 Bit Panda platformer.
The main character
First I needed a player character. I did not think much about this: the design process basically consisted of the question: “why not a panda?”, The answer to which was: “of course, why not?” So I started drawing my first sprite in the TIC-80 sprite editor:

I'll let you enjoy the impressive lack of artistic skills, but it’s worth considering that only 2,256 combinations of 16-color 8x8 sprites are possible . Only some of them will be pandas. If you do not come to the conclusion that this is the worst combination, then I will feel flattered.
Taking it as a basis, I drew a few more sprites representing the main character in other poses: walking, jumping, attack, etc.

Now that all readers who have expected to find panda drawing lessons here have definitely turned their back on me, let's turn to the code.
If you find the source code useful, you can study my source code . But this is not necessary, because I will try to tell as much context as possible on each topic. Of course, we will not study the entire source code, I just talk about interesting points that met during development.
Level Creation
The TIC-80 has a built-in map editor that you can (and should) use to create levels. Using it is quite simple: this is a regular matrix of tiles, each of which can be any of 256 sprites at the bottom of the sprite table (the top, with indices from 256 to 511, can be drawn during the game, but they cannot be on the map, because it requires 9 bits to display).
Sprite vs Tile:in TIC-80, “sprite” refers to one of 512 predefined images in an 8x8 pixel cartridge. Map tiles are just sprites (each map tile can be one of 256 sprites in the lower half of the sprite table). Therefore, we will say “sprite” when it comes to the graphic element, and “tile” when we mean the cell of the map, even though technically the cell contains a sprite. To summarize: all this does not matter, tiles and sprites are one and the same.
Using the map editor, for starters, I created a fairly simple “level”:

Firstly, it is worth noting that there are two main types of tiles:
- Hard tiles (earth and earth + grass tiles) on which the player can stand and which block the player’s movement.
- Decorative tiles (trees, grass, lantern, stone, etc.). They are needed only for beauty and do not affect anything in the game.
Later we will get to know the entity tiles, but for now we will not talk about them. The code needs to somehow communicate whether the tile is solid or not. I chose a simple approach and decided to limit myself to the sprite index (80): if the sprite index is <80, then the tile is solid. If it is ≥ 80, then the tile is used for decoration. Therefore, in the sprite table, I just drew all the solid tiles to index 80, and all decorative tiles after 80:

But wait, the water is not solid! What does she do with hard sprites? I did not tell you about overrides: there is a list of overrides of hardness of sprites which can replace default hardness. He tells us that, for example, the water tile is not really solid, even though it is in the sprite table in the part with hard tiles. But it is not decorative, because it affects the game.
Player Status
If I learned something during my career as a programmer, it is that global variables are bad, but they can be used if you come up with some interesting name, for example, “singleton”. Therefore, I defined several “singletones” that set the state of the game. I use this term quite arbitrarily, because it is not OOP, and in fact they are rather high-level struct, rather than real singletones.
Well, okay, it doesn’t matter what they are called. Let's start with Plr , which sets the player’s state at a particular point in time:
Plr={
lives=3,
x=0,y=0,
grounded=false,
...
}
It has many other fields, but the most important thing here is to note that this object stores the player’s entire state at the current level: where the player is at the level, whether he is jumping, standing on solid ground, swimming, returning to the surface again, dying, flies on an airplane (yes, this is one of those pandas that fly on airplanes), points, active bonuses, etc.
There is also a game state separate from the player state. For instance,
Game={
m=M.BOOT, -- текущий режим
lvlNo=0, -- уровень, в который мы играем
...
}
It stores values such as the current mode (we will talk about this in more detail), the current level, as well as data for the entire game, calculated during execution.
It is useful to separate the state of the game and the state of the player , because then it is enough to start / restart the levels: it is enough to simply reset and delete the state of the player without touching the state of the game.
Level and player rendering
Rendering a level and a player on the TIC-80 is incredibly simple. The only thing you need to do is call map () to draw (part) the map and spr () to draw sprites anywhere you want. Since I was drawing my level from the upper 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)
And we get the following:

And, of course, the panda remains in the corner, doing nothing. While this is not very similar to the game, but we have not finished yet.
Of course, everything becomes more complicated when we want to implement a side scroller, in which the camera accompanies the player when moving forward. I did the following: I created a Game.scr variable that determines how far the screen is scrolled to the right. Then, when rendering the screen, I move the map to the left by this number of pixels, and when rendering everything, I always subtract Game.scr to draw in the right place on the screen, something like this:
spr(S.PLR.STAND, Plr.x - Game.scr, Plr.y)
In addition, for efficiency, I determine which part of the level is visible from any point and draw only this rectangle of the map on the screen, and not its entirety. Ugly details can be found in the RendMap () function .
Now we need to write the logic moving the panda in response to the player’s actions.
Move the panda
I never thought that I would write an article with such a subtitle, but life is full of surprises. Panda is our main character, and in a platform game everything moves and jumps, so we can reasonably say that the “panda movement” is the core of the game.
The “movement” part is pretty simple: we just change Plr.x and Plr.y , after which the panda appears elsewhere. Therefore, the simplest implementation of the movement can be written something like this:
if btn(2) then
Plr.x = Plr.x - 1
elseif btn(3) then
Plr.x = Plr.x + 1
end
Remember that in the TIC-80, btn (2) is the left key, and btn (3) is the right key . But this way we can only move horizontally and cannot collide with walls. We need something more complex, taking into account gravity and obstacles.
function UpdatePlr()
if not IsOnGround() then
-- падать
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
If we implemented IsOnGround () correctly, then this will be a big improvement: the player will be able to move left and right, and fall when not on solid ground. So, we can already walk and fall from the cliffs. Amazing!
But at the same time, obstacles are not taken into account: what happens if we enter (horizontally) a hard tile standing in the way? We should not be able to enter there. That is, in general, we have such a scheme - in motion there are two stages:
- We decide where the hero wants to go (taking into account external factors such as gravity).
- We decide whether it is possible to allow the hero to move there (due to obstacles).
The concept of “desire to go” has a broad definition and sets an intentional and involuntary displacement: standing on solid ground, the hero “wants” to move down (due to gravity), but cannot, because when moving down he will collide with the earth.
Therefore, it makes sense for us to write a function that encodes the entire logic “can a hero move to a given position x, y”. But we will also need it when realizing enemies, because we will also have to ask "can this enemy move to the x, y position?" That is, for generalization, it would be best to write a function that receives x, y and an arbitrary collision rectangle at the input (this way we can correctly transfer the correct x, y and the collision rectangle of the essence of the hero or enemy):
C=8 -- константа размера тайла (в TIC-80 всегда 8)
-- Проверяем, есть ли у сущности прямоугольник коллизии
-- cr={x,y,w,h} может двигаться в позицию 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
-- проверяем все тайлы, которых касается прямоугольник
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 quite simple: we just find the borders of the rectangle, iteratively go through all the tiles that the rectangle touches, and check if there are solid ones among them ( IsTileSolid () just performs our test "≥ 80", plus overrides). If we do not find a solid tile in the path, then return true , meaning "well, you can move here . " If we find such a tile, then return false , meaning "no, you can’t move here . " Two situations are illustrated below.

Let's write another life-simplifying function that tries to move to a specific offset. if possible:
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 becomes much cleaner: first we decide where we want to go, then we check whether this is possible. If possible, then move there.
function UpdatePlr()
-- состояние "на земле" означает "мы не можем двигаться вниз"
Plr.grounded = not CanMove(Plr.x, Ply.y + 1)
if not Plr.grounded then
-- если не на земле, то падаем.
Plr.y = Plr.y + 1
end
local dx = btn(2) and -1 or (btn(3) and 1 or 0)
local dy = 0 -- мы реализуем прыжок позже
TryMoveBy(dx,dy)
end
Well, now we have a movement taking into account obstacles. Later, when we add solid entities (mobile platforms and others), we will have to complicate the function a little to check for collisions with entities, but the principle will remain the same.
Panda animations
If we always use one sprite (number 257), then the game will seem boring, because the panda will always be in the same standing pose. Therefore, we need the panda to walk / jump / attack, etc. We want the sprite to change based on the state of the player. To make it easier to access sprite numbers, we will declare constants:
-- S - таблица со всеми спрайтами
S={
-- S.PLR - таблица только со спрайтами игрока
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,
}
}
They correspond to several panda sprites in the sprite table:

So, in our rendering function, we decide which sprite to use. This is the RendPlr () function , which contains the following:
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 means: if the player is on solid ground and is walking, then perform a walk animation, alternately drawing sprites S.PLR.WALK1 and S.PLR.WALK2. If the player is on solid ground and not walking, use S.PLR.STAND. If not on hard ground (falls or jumps), then use S.PLR.JUMP.
There is also additional logic for determining the side the hero is looking at, performing animation sequences for attacks and jumps, and for adding sprite overlays when creating bonuses.
Jumping
People expect a strange thing: when we jump in real life, we really can do little to change the trajectory of the jump, but when playing platform games we want (or rather require ) that the character can arbitrarily change the trajectory of the jump in the air. Therefore, like the characters of many other games, our panda will have the ability to move freely in the air, which is contrary to physics.
In fact, this greatly simplifies the implementation of jumps. A jump is, in essence, a sequence of changes in the Y coordinate of the hero. The X coordinate is freely changed with the arrow keys, as if the player is on the ground.
We will present the jump as an iteration of the "jump sequence":
JUMP_DY={-3,-3,-3,-3,-2,-2,-2,-2,1,1,0,0,0,0,0}
When a player jumps, his position in Y changes in each frame by the value indicated in the sequence. The variable that tracks our current place in the jump sequence is called Plr.jmp .
The logic of the start / end of the jump will be something like this:
- If we are on hard ground and press the jump button ( btn (4) ), start the jump (set Plr.jmp = 1 ).
- If the jump is already performed ( Plr.jmp > 0), then continue the jump, trying to change the player's position in Y by JUMP_DY [Plr.jmp] , if possible (in accordance with the CanMove () function ).
- At some point in the jump, something is preventing the player from moving ( CanMove () returns false), then we stop the jump (set Plr.jmp = 0 and begin to fall).
The resulting trajectory of the jump will not look like a perfect parabola, but it is quite suitable for our purposes. Falling after the jump is a straight line, because on the way down we do not apply acceleration. I tried to add it, but it looked strange, so I decided to do without acceleration. In addition, a drop rate of 1 pixel / cycle gives us the ability to use some pretty tricky tricks to detect collisions.

Trajectory of the jump.
Entities
Tiles are good, but they are static. The only thing you can enjoy is jumping over the motionless blocks of the earth. To revive our platformer, we need enemies, bonuses, etc. All of these “moving or interactive objects” are called entities.
For starters, I drew some awesome enemies. Basically, they are horrified by the quality of the drawing, and not by the fact that they are scary:

I just added them to the sprite table and created animations for each. I also set a new split point: sprites with indices ≥ 128 are entities , not static tiles. So I can just add enemies to the level using the map editor, and I will know that they are enemies thanks to their sprite index:

Similarly, many other objects are entities: chests, destructible blocks, temporary platforms, elevators, portals, etc.
When loading a level, I check every tile on the map. If it is ≥ 128, I delete the map tile and create an entity at that location. Entity ID (EID) determines what it is. What do we use as an EID? Yes, just take the sprite number again! That is, if the enemy “green slug” has a sprite of 180, then the EID of the green slug will be 180. It's simple.
All enemies are stored in the global Ents structure .
Entity Animations
Entities can be animated. Instead of manually encoding the animation for each type of enemy, I simply created a large table of animations indexed by EID, which determines which sprites should be displayed cyclically:
-- цикл анимации для каждого 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 (for example, S.EN.DEMON), when they also coincide with the sprite of the entity, and some are hard-coded integers (292), because in the second case this is just a secondary frame of the animation, which no longer need to be referenced.
When rendering, we can simply find the desired animation in this table and render the correct sprite for each entity.
Meta Tags: Map Annotations
Sometimes we need to add annotations to cards that are used during game execution. For example, if there is a treasure chest, then we need to somehow indicate what is inside and how many of these objects. In these cases, we use map annotation markers, these are special tiles with numbers 0–12 that are never displayed (they are removed from the map at run time):

When the level loader sees the chest, he looks at the tile above the chest to find out its contents, and looks for a special numerical marker indicating the amount. objects to be created. Therefore, when a player hits the chest, all items are created:

Meta tags can also determine, for example, where elevators should start and stop moving, where the initial position of the level is located, phase information for temporary platforms, etc.
They are also used for level compression. We will talk about this below.
Levels
The game has 17 levels. Where are they stored? Well, if you look at the memory of cards, you will see the following picture:

The TIC-80 has 64 “pages” of cards, each of which is one “screen” of content (30x17 tiles). The pages are numbered from 0 to 63.
In our scheme, we reserved the top 8 for use during the execution of the game. There we will store the level after unpacking (more on this later). Then each level is a sequence of 2 or 3 pages in the memory of cards. We can also create pages for the training screen, victory screen, world map and home screen. Here is the version of the map with annotations:

If you play the game, you may notice that the levels are actually much longer than they could fit on 2 or 3 screens. But in the memory of cartridge cards, they are much smaller. What is going on here?
You guessed it (and I gave a hint): the levels are compressed! In the memory of the cards, each column stores a meta tag on the top line , indicating how many times the column is repeated . In other words, we implemented a simple form of RLE compression:

Therefore, when this page is unpacked, it actually defines a much longer part of the level (almost twice as much, and even more on some levels).
But for what we use the top 8 pages of maps at runtime: when we are ready to play a level, we unpack it for the gameplay on pages 0–7. Here is the logic used to run the level:
- We read the packed level from the right place in the memory of the cards.
- Unpack it into the top 8 pages in accordance with the number of repetitions in each column of the packed level.
- We search for entities (sprites ≥ 128) and create their instances.
- We are looking for the player’s starting position (metamarker “A”) and put the player there.
- Start to play.
Entity Behaviors
What makes an enemy behave in a certain way? Take for example the red demon from the game. Where does his passion for throwing fireballs come from? Why don't they just become friends?

Each entity has its own behavior. Demons throw balls of fire and jump. Green slugs roam back and forth. Blue monsters periodically jump. Icicles fall when a player is fast enough. Destructible blocks are destroyed. Lifts rise. Chests remain chests until a player hits them, after which they open and create their contents.
An easy way to create all of this logic would be to:
if enemy.eid == EID.DEMON then
ThrowFireballAtPlayer()
elseif enemy.eid == EID_SLIME then
-- делаем что-то другое
elseif enemy.eid == EID_ELEVATOR then
-- делаем что-то другое
-- ...и так далее для каждого случая...
Поначалу всё кажется простым. Но потом работа становится сложной и монотонной. Возьмём к примеру движение: когда враг движется, нам нужно выполнить кучу проверок — убедиться, что враг может пройти в заданную позицию, проверить, не упадёт ли он, и т.д. Но некоторые враги не падают, они летают (например летучие мыши). А некоторые плавают (рыбы). Некоторые враги хотят смотреть на игрока, другие нет. Некоторые враги хотят преследовать игрока. Некоторые просто занимаются своим делом, не зная, где находится игрок. А как насчёт снарядов? Огненные шары, плазменные шары, снежки. На некоторые влияет гравитация, на некоторые нет. Некоторые сталкиваются с твёрдыми объектами, другие нет. Что происходит, когда каждый из снарядов ударяет по игроку? Что происходит, когда игрок их ударяет? Так много переменных и комбинаций!
Writing if / else blocks for each of these cases after some time becomes a burden. Therefore, instead, we will create a behavior system that is a fairly common and useful pattern in game development. First, we will determine all the possible behaviors that the entity may have and the necessary parameters. For example, she can:
- Move (How much? How does she handle the edges of the ledges? What happens when she encounters something solid?)
- Fall
- Change direction (How often? Always looks at the player?)
- Jump (How often? How high?)
- Shoot (What? In which direction? Aim at the player?)
- Be vulnerable (take damage from a player).
- Damage a player in a collision
- Collapse in a collision (How long?)
- Automatically collapse after a given period of time (How long?)
- Give Bonus (Which?)
- etc...
Having defined all the possible behaviors, we simply assign them to the correct entities with the necessary parameters, in which we call the EBT (Entity-Behavior Table, table of entity behaviors). Here is an example entry for the red daemon:
-- Таблица поведений сущностей
EBT={
...
[EID.EN.DEMON]={
-- Поведения:
beh={BE.JUMP,BE.FALL,BE.SHOOT,
BE.HURT,BE.FACEPLR,BE.VULN},
-- Параметры поведений:
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 reports that demons have the following behaviors: jump, fall, shoot, damage, turn to the player, vulnerability. In addition, the parameters indicate that the entity has one damage point, moves every 5 cycles, has a base color of 7 (red), shoots with fireballs (EID.FIREBALL), targets a player in a horizontal plane (AIM.HORIZ), has a chance of falling out rewards of 60%, can throw food C and D (sushi). See - now we can set the whole behavior of this enemy in just a few lines by a combination of different behaviors!
What about these mountains in the background?
Oh, you noticed mountains in the background! If you look at the memory cards, then there you will not find them. They also do not move exactly with the player: they have a parallax effect and move slower than the foreground, giving the impression that they are far from the background.
How is this effect obtained? In fact, we create mountains at runtime. When loading the level, we randomly (but with a constant initial number of the generator) generate an elevation map with one value per cell, and make it a little more than the level (in general, about 300 values or so).
When rendering the scene, we use the player’s position to determine the displacement of the elevation map (dividing it by a constant to obtain the parallax effect), and then render the mountains using the sprites of the parts of the mountains from the sprite table. This is fast because we only render the mountains that are currently visible (they are easy to identify by looking at Game.scr and doing the same calculations).
Palette Overrides
One of the great features of the TIC-80 is that it allows you to redefine the palette by writing values to some RAM memory addresses. This means that each of our levels can have a "redefinition of the palette", which we set at startup:
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
RAM address 0x3fc0 is the beginning of the area in which the TIC-80 stores its palette, so to change the palette, we just need to write bytes to this memory.
World map
I added a world map in the later stages of development. It serves to inform the player about his progress. It shows 17 levels of the game scattered across 6 islands:

The player can move with the arrow keys and enter the level by pressing Z. The world map is implemented in two map pages: front and background .
The background page (page 62 in the map memory) simply contains the static parts of the map:

At runtime, the front page is superimposed on it (page 61):

This page shows where the levels are. Tiles "1", "2" and "3" are levels. The game finds out which islands they belong to by performing a metamarker search (1–6) around the tile. That is, for example, when the game looks at the “2” tile on island 3 (on the right side of the map), she notices that there is a “3” metamarker next to it, that is, she learns that this is level 3–2.
Marker “A” indicates the starting position of the player, and marker “B” - the starting position of each island when restoring a saved game.
Sound Effects and Music
I wrote all the sound effects and music using the built-in TIC-80 editor. I would like to say that I have a good way to create sound effects, but in fact I just clicked on the buttons and accidentally changed the numbers until I got the right one. I think this is a very reasonable way to create retro sound effects.

To create background music, I read a little about how to compose music, because I had never done this before. It turns out that even with very rudimentary knowledge, one can compose something that sounds quite acceptable (or at least as acceptable to hearing as my terrible panda graphics are acceptable to the eye).

I composed 8 tracks (limit for TIC-80):
- Music A for levels, used on islands 1–5.
- Music B for levels, used on islands 1–5.
- Ringtone end level (short).
- C music for levels, used on islands 1–5.
- World map music.
- Home screen theme.
- Island level 6 music (last 2 levels)
- Game End Theme (The End Screen)
Conclusion
The creation of this game brought me an incredible amount of joy, and I am deeply grateful to the creator of TIC-80 ( Vadim Grigoruk ) for the appearance of this excellent platform. I hope you also enjoy playing the game (and hacking the source code if you want!), As I liked creating it!
In the future I am going to write other games for the TIC-80 and similar consoles.