Balatro-Inspired Spinning Card Tweetcart Breakdown
from andy
I recently made a tweetcart of a spinning playing card inspired by finally playing Balatro, the poker roguelike everybody is talking about.
If you don't know what a tweetcart is, it's a type of size-coding where people write programs for the Pico-8 fantasy console where the source code is 280 characters of less, the length of a tweet.
I'm actually not on twitter any more, but I still like 280 characters as a limitation. I posted it on my mastodon and my tumblr.
Here's the tweetcart I'm writing about today:
And here is the full 279 byte source code for this animation:
a=abs::_::cls()e=t()for r=0,46do
for p=0,1,.025do
j=sin(e)*20k=cos(e)*5f=1-p
h=a(17-p*34)v=a(23-r)c=1+min(23-v,17-h)%5/3\1*6u=(r-1)/80z=a(p-.2)if(e%1<.5)c=a(r-5)<5and z<u+.03and(r==5or z>u)and 8or 8-sgn(h+v-9)/2
g=r+39pset((64+j)*p+(64-j)*f,(g+k)*p+(g-k)*f,c)end
end
flip()goto _
You can copy/paste that code into a blank Pico-8 file to try it yourself. I wrote it on Pico-8 version 0.2.6b.
I'm very pleased with this cart! From a strictly technical perspective I think it's my favorite that I've ever made. There is quite a bit going on to make the fake 3D as well as the design on the front and back of the card. In this post I'll be making the source code more readable as well as explaining some tools that are useful if you are making your own tweetcarts or just want some tricks for game dev and algorithmic art.
Expanding the Code
Tweetcarts tend to look completely impenetrable, but they are often less complex than they seem. The first thing to do when breaking down a tweetcart (which I highly recommend doing!) is to just add carriage returns after each command.
Removing these line breaks is a classic tweetcart method to save characters. Lua, the language used in Pico-8, often does not need a new line if a command does not end in a letter, so we can just remove them. Great for saving space, bad for readability. Here's that same code with some line breaks, spaces and indentation added:
a=abs
::_::
cls()
e=t()
for r=0,46 do
for p=0,1,.025 do
j=sin(e)*20
k=cos(e)*5
f=1-p
h=a(17-p*34)
v=a(23-r)
c=1+min(23-v,17-h)%5/3\1*6
u=(r-1)/80
z=a(p-.2)
if(e%1<.5) c= a(r-5) < 5 and z < u+.03 and (r==5 or z>u) and 8 or 8-sgn(h+v-9)/2
g=r+39
pset((64+j)*p+(64-j)*f,(g+k)*p+(g-k)*f,c)
end
end
flip()goto _
Note: the card is 40 pixels wide and 46 pixels tall. Those number will come up a lot. As will 20 (half of 40) and 23 (half of 46).
Full Code with Variables and Comments
Finally, before I get into what each section is doing, here is an annotated version of the same code. In this code, variables have real names and I added comments:
::_::
cls()
time = t()
-- the card has 46 vertical rows
for row = 0,46 do
-- the card is drawn as individual points moving in a line horizontaly across the card
-- this is represented as a percentage going from 0 to 1
for prc = 0,1,.025 do
-- get the offset of the top of the card as it spins
-- pnt A will use the positive values and point B will use the negative ones
x_dist = sin(time)*20
y_dist = cos(time)*5
-- both the diamond and the card back make use of knowing how far this pixel is from the center
horizontal_dist_from_center = abs(17-prc*34)
vertical_dist_from_center = abs(23-row)
-- initially set the color assuming that we are on the back side
-- this will either be 1 (dark blue) or 7 (white)
color = 1 + min(23-vertical_dist_from_center,17-horizontal_dist_from_center)%5/3\1*6
-- for the diamond and the little "A" we do a whole lot of calculations
-- most of this is for the A. The diamond shape really just cares if the combined vertical and horizontal distance is greater than 9
-- the resulting color will be either 7 (white) or 8 (red)
slope = (row-1)/80
dist_from_center = abs(prc-.2)
if(time %1<.5) color = abs(row-5) < 5 and dist_from_center < slope+.03 and (row==5 or dist_from_center > slope) and 8 or 8-sgn(horizontal_dist_from_center+vertical_dist_from_center-9)/2
-- now we figure out where to draw the pixel as a percentage between two points defined by x_dist and y_dist
-- these points are on opposite sides of the circle
y_pos = row+39
pset( (64+x_dist)*prc + (64-x_dist)*(1-prc), (y_pos+y_dist)*prc + (y_pos-y_dist)*(1-prc), color)
end
end
-- draw everything to the screen and return to ::_::
flip()goto _
This may be all you need to get a sense of how I made this animation, but the rest of this post will be looking at how each section of the code contributes to the final effect. Part of why I wanted to write this post is because I was happy with how many different tools I managed to use in such a small space.
flip() goto_
This pattern shows up in nearly every tweetcart:
::_::
MOST OF THE CODE
flip()goto _
This has been written about in Pixienop's Tweetcart Basics which I highly recommend for anybody curious about the medium! The quick version is that using goto
is shorter than declaring the full draw function that Pico-8 carts usually use.
Two Spinning Points
The card is drawn in rows starting from the top and going to the bottom. Each of these lines is defined by two points that move around a center point in an elliptical orbit.
The center of the top of the card is x=64 (dead center) and y=39 (a sort of arbitrary number that looked nice).
Then I get the distance away from that center that my two points will be using trigonometry.
x_dist = sin(time)*20
y_dist = cos(time)*5
Here are those points:
P1 adds x_dist
and y_dist
to the center point and P2 subtracts those same values.
Those are just the points for the very top row. The outer for loop is the vertical rows. The center x position will be the same each time, but the y position increases with each row like this: y_pos = row+39
Here's how it looks when I draw every 3rd row going down:
It is worth noting that Pico-8 handles sin()
and cos()
differently than most languages. Usually the input values for these functions are in radians (0 to two pi), but in Pico-8 it goes from 0 to 1. More info on that here. It takes a little getting used to but it is actually very handy. More info in a minute on why I like values between 0 and 1.
Time
In the shorter code, e
is my time
variable. I tend to use e
for this. In my mind it stands for “elapsed time”. In Pico-8 time() returns the current elapsed time in seconds. However, there is a shorter version, t()
, which obviously is better for tweetcarts. But because I use the time value a lot, even the 3 characters for t()
is often too much, so I store it in the single-letter variable e
.
Because it is being used in sine and cosine for this tweetcart, every time e
reaches 1, we've reached the end of a cycle. I would have liked to use t()/2
to slow this cart down to be a 2 second animation, but after a lot of fiddling I wound up being one character short. So it goes.
e
is used in several places in the code, both to control the angle of the points and to determine which side of the card is facing the camera.
Here you can see how the sine value of e
controls the rotation and how we go from showing the front of the card to showing the back when e%1
crosses the threshold of 0.5.
Drawing and Distorting the Lines
Near the top and bottom of the loop we'll find the code that determines the shape of the card and draws the horizontal lines that make up the card. Here is the loop for drawing a single individual line using the code with expanded variable names:
for prc = 0,1,.025 do
x_dist = sin(time)*20
y_dist = cos(time)*5
...
y_pos = row+39
pset( (64+x_dist)*prc + (64-x_dist)*(1-prc), (y_pos+y_dist)*prc + (y_pos-y_dist)*(1-prc), color)
end
You might notice that I don't use Pico-8's line function! That's because each line is drawn pixel by pixel.
This tweetcart simulates a 3D object by treating each vertical row of the card as a line of pixels. I generate the points on either side of the card(p1 and p2 in this gif), and then interpolate between those two points. That's why the inner for loop creates a percentage from 0 to 1 instead of pixel positions. The entire card is drawn as individual pixels. I draw them in a line, but the color may change with each one, so they each get their own pset() call.
Here's a gif where I slow down this process to give you a peek at how these lines are being drawn every frame. For each row, I draw many pixels moving across the card between the two endpoints in the row.
Here's the loop condition again: for prc = 0,1,.025 do
A step of 0.025 means there are 40 steps (0.025 * 40 = 1.0). That's the exact width of the card! When the card is completely facing the camera head-on, I will need 40 steps to make it across without leaving a gap in the pixels. When the card is skinnier, I'm still drawing all 40 pixels, but many of them will be in the same place. That's fine. The most recently drawn one will take priority.
Getting the actual X and Y position
I said that the position of each pixel is interpolated between the two points, but this line of code may be confusing:
y_pos = row+39
pset( (64+x_dist)*prc + (64-x_dist)*(1-prc), (y_pos+y_dist)*prc + (y_pos-y_dist)*(1-prc), color)
So let's unpack it a little bit. If you've ever used a Lerp() function in something like Unity you've used this sort of math. The idea is that we get two values (P1 and P2 in the above example), and we move between them such that a value of 0.0 gives us P1 and 1.0 gives us P2.
Here's a full cart that breaks down exactly what this math is doing:
::_::
cls()
time = t()/8
for row = 0,46 do
for prc = 0,1,.025 do
x_dist = sin(time)*20
y_dist = cos(time)*5
color = 9 + row % 3
p1x = 64 + x_dist
p1y = row+39 + y_dist
p2x = 64 - x_dist
p2y = row+39 - y_dist
x = p2x*prc + p1x*(1-prc)
y = p2y*prc + p1y*(1-prc)
pset( x, y, color)
end
end
flip()goto _
I'm defining P1 and P2 very explicitly (getting an x and y for both), then I get the actual x and y position that I use by multiplying P2 by prc
and P1 by (1-prc)
and adding the results together.
This is easiest to understand when prc
is 0.5, because then we're just taking an average. In school we learn that to average a set of numbers you add them up and then divide by how many you had. We can think of that as (p1+p2) / 2
. This is the same as saying p1*0.5 + p2*0.5
.
But the second way of writing it lets us take a weighted average if we want. We could say p1*0.75 + p2*0.25
. Now the resulting value will be 75% of p1 and 25% of p2. If you laid the two values out on a number line, the result would be just 25% of the way to p2. As long as the two values being multiplied add up to exactly 1.0 you will get a weighted average between P1 and P2.
I can count on prc
being a value between 0 and 1, so the inverse is 1.0 - prc
. If prc
is 0.8 then 1.0-prc
is 0.2. Together they add up to 1!
I use this math everywhere in my work. It's a really easy way to move smoothly between values that might otherwise be tricky to work with.
Compressing
I'm using a little over 400 characters in the above example. But in the real cart, the relevant code inside the loops is this:
j=sin(e)*20
k=cos(e)*5
g=r+39
pset((64+j)*p+(64-j)*f,(g+k)*p+(g-k)*f,c)
which can be further condensed by removing the line breaks:
j=sin(e)*20k=cos(e)*5g=r+39pset((64+j)*p+(64-j)*f,(g+k)*p+(g-k)*f,c)
Because P1, P2 and the resulting interpolated positions x and y are never used again, there is no reason to waste chars by storing them in variables. So all of the interpolation is done in the call to pset()
.
There are a few parts of the calculation that are used more than once and are four characters or more. Those are stored as variables (j, k & g in this code). These variables tend to have the least helpful names because I usually do them right at the end to save a few chars so they wind up with whatever letters I have not used elsewhere.
Spinning & Drawing
Here's that same example, but with a checker pattern and the card spinning. (Keep in mind, in the real tweetcart the card is fully draw every frame and would not spin mid-draw)
This technique allows me to distort the lines because I can specify two points and draw my lines between them. Great for fake 3D! Kind of annoying for actually drawing shapes, because now instead of using the normal Pico-8 drawing tools, I have to calculate the color I want based on the row (a whole number between0 and 46) and the x-prc (a float between 0 and 1).
Drawing the Back
Here's the code that handles drawing the back of the card:
h=a(17-p*34)
v=a(23-r)
c=1+min(23-v,17-h)%5/3\1*6
This is inside the nested for loops, so r
is the row and p
is a percentage of the way across the horizontal line.
c
is the color that we will eventually draw in pset()
.
h
and v
are the approximate distance from the center of the card. a
was previously assigned as a shorthand for abs()
so you can think of those lines like this:
h=abs(17-p*34)
v=abs(23-r)
v
is the vertical distance. The card is 46 pixels tall so taking the absolute value of 23-r will give us the distance from the vertical center of the card. (ex: if r is 25, abs(23-r) = 2. and if r is 21, abs(23-r) still equals 2 )
As you can probably guess, h
is the horizontal distance from the center. The card is 40 pixels wide, but I opted to shrink it a bit by multiplying p
by 34 and subtracting that from half of 34 (17). The cardback just looks better with these lower values, and the diamond looks fine.
The next line, where I define c
, is where things get confusing. It's a long line doing some clunky math. The critical thing is that when this line is done, I need c
to equal 1 (dark blue) or 7 (white) on the Pico-8 color pallette.
Here's the whole thing: c=1+min(23-v,17-h)%5/3\1*6
Here is that line broken down into much more discrete steps.
c = 1 --start with a color of 1
low_dist = min(23-v,17-h) --get the lower inverted distance from center
val = low_dist % 5 --mod 5 to bring it to a repeating range of 0 to 5
val = val / 3 --divide by 3. value is now 0 to 1.66
val = flr(val) --round it down. value is now 0 or 1
val = val * 6 --multiply by 6. value is now 0 or 6
c += val --add value to c, making it 1 or 7
The first thing I do is c=1
. That means the entire rest of the line will either add 0 or 6 (bumping the value up to 7). No other outcome is acceptable. min(23-v,17-h)%5/3\1*6
will always evaluate to 0 or 6.
I only want the lower value of h
and v
. This is what will give it the nice box shape. If you color the points inside a rectangle so that ones that are closer to the center on their X are one color and ones that are closer to the center on their Y are a different color you'll get a pattern with clean diagonal lines running from the center towards the corners like this:
You might think I would just use min(v,h)
instead of the longer min(23-v,17-h)
in the actual code. I would love to do that, but it results in a pattern that is cool, but doesn't really look like a card back.
I take the inverted value. Instead of having a v
that runs from 0 to 23, I flip it so it runs from 23 to 0. I do the same for h
. I take the lower of those two values using min()
.
Then I use modulo (%) to bring the value to a repeating range of 0 to 5. Then I divide that result by 3 so it is 0 to ~1.66. The exact value doens't matter too much because I am going round it down anyway. What is critical is that it will become 0 or 1 after rounding because then I can multiply it by a specific number without getting any values in between.
Wait? If I'm rounding down, where is flr()
in this line: c=1+min(23-v,17-h)%5/3\1*6
?
It's not there! That's because there is a sneaky tool in Pico-8. You can use \1
to do the same thing as flr()
. This is integer division and it generally saves a 3 characters.
Finally, I multiply the result by 6. If it is 0, we get 0. If it is 1 we get 6. Add it to 1 and we get the color we want!
Here's how it looks with each step in that process turned on or off:
A Note About Parentheses
When I write tweetcarts I would typically start by writing this type of line like this: c=1+ (((min(23-v,17-h)%5)/3) \1) *6
This way I can figure out if my math makes sense by using parentheses to ensure that my order of operations works. But then I just start deleting them willy nilly to see what I can get away with. Sometimes I'm surprised and I'm able to shave off 2 characters by removing a set of parentheses.
The Face Side
The face side with the diamond and the “A” is a little more complex, but basically works the same way as the back. Each pixel needs to either be white (7) or red (8). When the card is on this side, I'll be overwriting the c
value that got defined earlier.
Here's the code that does it (with added white space). This uses the h
and v
values defined earlier as well as the r
and p
values from the nested loops.
u=(r-1)/80
z=a(p-.2)
if(e%1<.5) c= a(r-5) < 5 and z < u+.03 and (r==5 or z>u) and 8 or 8-sgn(h+v-9)/2
Before we piece out what this is doing, we need to talk about the structure for conditional logic in tweetcarts.
The Problem with If Statements
The lone line with the if statement is doing a lot of conditional logic in a very cumbersome way designed to avoid writing out a full if statement.
One of the tricky things with Pico-8 tweetcarts is that the loop and conditional logic of Lua is very character intensive. While most programming language might write an if statement like this:
if (SOMETHING){
CODE
}
Lua does it like this:
if SOMETHING then
CODE
end
Using “then” and “end” instead of brackets means we often want to bend over backwards to avoid them when we're trying to save characters.
Luckily, Lua lets you drop “then” and “end” if there is a single command being executed inside the if.
This means we can write
if(e%1 < 0.5) c=5
instead of
if e%1 < 0.5 then c=5 end
This is a huge savings! To take advantage of this, it is often worth doing something in a slightly (or massively) convoluted way if it means we can reduce it to a single line inside the if. This brings us to:
Lua's Weird Ternary Operator
In most programming language there is an inline syntax to return one of two values based on a conditional. It's called the Ternary Operator and in most languages I use it looks like this:
myVar = a>b ? 5 : 10
The value of myVar
will be 5 if a
is greater than b
. Otherwise is will be 10.
Lua has a ternary operator... sort of. You can read more about it here but it looks something like this:
myVar = a>b and 5 or 10
Frankly, I don't understand why this works, but I can confirm that it does.
In this specific instance, I am essentially using it to put another conditional inside my if statement, but by doing it as a single line ternary operation, I'm keeping the whole thing to a single line and saving precious chars.
The Face Broken Out
The conditional for the diamond and the A is a mess to look at. The weird syntax for the ternary operator doesn't help. Neither does the fact that I took out any parentheses that could make sense of it.
Here is the same code rewritten with a cleaner logic flow.
--check time to see if we're on the front half
if e%1 < .5 then
--this if checks if we're in the A
u=(r-1)/80
z=a(p-.2)
if a(r-5) < 5 and z < u+.03 and (r==5 or z>u) then
c = 8
--if we're not in the A, set c based on if we're in the diamond
else
c = 8-sgn(h+v-9)/2
end
end
The first thing being checked is the time. As I explained further up, because the input value for sin()
in Pico-8 goes from 0 to 1, the midpoint is 0.5. We only draw the front of the card if e%1
is less than 0.5.
After that, we check if this pixel is inside the A on the corner of the card or the diamond. Either way, our color value c
gets set to either 7 (white) or 8 (red).
Let's start with diamond because it is easier.
The Diamond
This uses the same h
and v
values from the back of the card. The reason I chose diamonds for my suit is that they are very easy to calculate if you know the vertical and horizontal distance from a point! In fact, I sometimes use this diamond shape instead of proper circular hit detection in size-coded games.
Let's look at the line: c = 8-sgn(h+v-9)/2
This starts with 8, the red color. Since the only other acceptable color is 7 (white), tha means that sgn(h+v-9)/2
has to evaluate to either 1 or 0.
sgn() returns the sign of a number, meaning -1 if the number is negative or 1 if the number is positive. This is often a convenient way to cut large values down to easy-to-work-with values based on a threshold. That's exactly what I'm doing here!
h+v-9
takes the height from the center plus the horizontal distance from the center and checks if the sum is greater than 9. If it is, sgn(h+v-9)
will return 1, otherwise -1. In this formula, 9 is the size of the diamond. A smaller number would result in a smaller diamond since that's the threshold for the distance being used. (note: h+v
is NOT the actual distance. It's an approximation that happens to make a nice diamond shape.)
OK, but adding -1 or 1 to 8 gives us 7 or 9 and I need 7 or 8.
That's where /2
comes in. Pico-8 defaults to floating point math, so dividing by 2 will turn my -1 or 1 into -0.5 or 0.5. So this line c = 8-sgn(h+v-9)/2
actually sets c
to 7.5 or 8.5. Pico-8 always rounds down when setting colors so a value of 7.5 becomes 7 and 8.5 becomes 8. And now we have white for most of the card, and red in the space inside the diamond!
The A
The A on the top corner of the card was the last thing I added. I finished the spinning card with the card back and the diamond and realized that when I condensed the whole thing, I actually had about 50 characters to spare. Putting a letter on the ace seemed like an obvious choice. I struggled for an evening trying to make it happen before deciding that I just couldn't do it. The next day I took another crack at it and managed to get it in, although a lot of it is pretty ugly! Luckily, in the final version the card is spinning pretty fast and it is harder to notice how lopsided it is.
I mentioned earlier that my method of placing pixels in a line between points is great for deforming planes, but makes a lot of drawing harder. Here's a great example. Instead of just being able to call print("a")
or even using 3 calls to line()
I had to make a convoluted conditional to check if each pixel is “inside” the A and set it to red if it is.
I'll do my best to explain this code, but it was hammered together with a lot of trial and error. I kept messing with it until I found an acceptable balance between how it looked and how many character it ate up.
Here are the relevant bits again:
u=(r-1)/80
z=a(p-.2)
if a(r-5) < 5 and z < u+.03 and (r==5 or z>u) then
c = 8
The two variables above the if are just values that get used multiple times. Let's give them slightly better names. While I'm making edits, I'll expand a
too since that was just a replacement for abs()
.
slope = (r-1)/80
dist_from_center = abs(p-.2)
if abs(r-5) < 5 and dist_from_center < slope+.03 and (r==5 or dist_from_center>slope) then
c = 8
Remember that r
is the current row and p
is the percentage of the way between the two sides where this pixel falls.
u
/slope
here is basically how far from the center line of the A the legs are at this row. As r
increases, so does slope
(but at a much smaller rate). The top of the A is very close to the center, the bottom is further out. I'm subtracting 1 so that when r
is 0, slope
is negative and will not be drawn. Without this, the A starts on the very topmost line of the card and looks bad.
z
/dist_from_center
is how far this particular p
value is from the center of the A (not the center of the card), measured in percentage (not pixels). The center of the A is 20% of the way across the card. This side of the card starts on the right (0% is all the way right, 100% is all the way left), which is why you see the A 20% away from the right side of the card.
These values are important because the two legs of the A are basically tiny distance checks where the slope
for a given r
is compared against the dist_from_center
. There are 3 checks used to determine if the pixel is part of the A.
if a(r-5) < 5 and z < u+.03 and (r==5 or z>u) then
The first is abs(r-5) < 5
. This checks if r
is between 1 and 9, the height of my A.
The second is dist_from_center < slope+.03
. This is checking if this pixel's x distance from the center of the A is no more than .03 bigger than the current slope value. This is the maximum distance that will be considered “inside” the A. All of this is a percentage, so the center of the A is 0.20 and the slope value will be larger the further down the A we get.
Because I am checking the distance from the center point (the grey line in the image above), this works on either leg of the A. On either side, the pixel can be less than slope+.03
away.
Finally, it checks (r==5 or dist_from_center>slope)
. If the row is exactly 5, that is the crossbar across the A and should be red. Otherwise, the distance value must be greater than slope
(this is the minimum value it can have to be “inside” the A). This also works on both sides thanks to using distance.
Although I am trying to capture 1-pixel-wide lines to draw the shape of the A, I could not think of a cleaner way than doing this bounding check. Ignoring the crossbar on row 5, you can think about the 2nd and 3rd parts of the if statement essentially making sure that dist_from_center
fits between slope
and a number slightly larger than slope
. Something like this:
slope < dist_from_center < slope+0.03
Putting it Together
All of this logic needed to be on a single line to get away with using the short form of the if statement so it got slammed into a single ternary operator. Then I tried removing parentheses one at a time to see what was structurally significant. I wish I could say I was more thoughtful than that but I wasn't. The end result is this beefy line of code:
if(e%1<.5)c=a(r-5)<5and z<u+.03and(r==5or z>u)and 8or 8-sgn(h+v-9)/2
Once we've checked that e
(our time value) is in the phase where we show the face, the ternary operator checks if the pixel is inside the A. If it is, c
is set to 8 (red). If it isn't, then we set c = 8-sgn(h+v-9)/2
, which is the diamond shape described above.
That's It!
Once we've set c
the tweetcart uses pset
to draw the pixel as described in the section on drawing the lines.
Here's the full code and what it looks like when it runs again. Hopefully now you can pick out more of what's going on!
a=abs::_::cls()e=t()for r=0,46do
for p=0,1,.025do
j=sin(e)*20k=cos(e)*5f=1-p
h=a(17-p*34)v=a(23-r)c=1+min(23-v,17-h)%5/3\1*6u=(r-1)/80z=a(p-.2)if(e%1<.5)c=a(r-5)<5and z<u+.03and(r==5or z>u)and 8or 8-sgn(h+v-9)/2
g=r+39pset((64+j)*p+(64-j)*f,(g+k)*p+(g-k)*f,c)end
end
flip()goto _
I hope this was helpful! I had a lot of fun writing this cart and it was fun to break it down. Maybe you can shave off the one additional character needed to slow it down by using e=t()/2
a bit. If you do, please drop me a line on my mastodon or tumblr!
And if you want to try your hand at something like this, consider submitting something to TweetTweetJam hosted by Andrew Resit which just started! You'll get a luxurious 500 characters to work with!
Links and Resources
There are some very useful posts of tools and tricks for getting into tweetcarts. I'm sure I'm missing many but here are a few that I refer to regularly.
Pixienop's tweetcart basics and tweetcart studies are probably the single best thing to read if you want to learn more.
Trasevol_Dog's Doodle Insights are fascinating, and some of them demonstrate very cool tweetcart techniques.
Optimizing Character Count for Tweetcarts by Eli Piilonen / @2DArray
Guide for Making Tweetcarts by PrincessChooChoo
The official documentation for the hidden P8SCII Control Codes is worth a read. It will let you do wild things like play sound using the print()
command.
I have released several size-coded Pico-8 games that have links to heavily annotated code:
And if you want to read more Pico-8 weirdness from me, I wrote a whole post on creating a networked Pico-8 tribute to Frog Chorus.