I've been writing tweetcarts semi-regularly for the past few months, and I've had a tonne of fun doing it, so I thought I'd write up some guides to share what I've learned, and help other people get started. While it can seem intimidating at first, I've been surprised by how approachable it's been.
It is perhaps appropriate that the cosiest game dev environment has a similarly cosy demo scene. You'd be surprised how far you can get with just a few tricks and some lateral thinking, and even something fairly simple can be impressive when you know it fits in a tweet!
If you're not sure where to get started, I'd suggest you check out some existing tweetcarts, and try to add your own spin to them. That's how I ended up getting into tweetcarts: I had just learned about the concept, and I saw this cart by Luca Harris:
[tweet ]
And after thinking a little about how it worked, I was inspired to try making something similar.
This is what I came up with:
[tweet ]
So in this first guide, I'm going to dissect this code, and talk a little about the thought process of making it. I'll say now that this is a fairly simple example, and if you're looking for something a bit more advanced, I'll maybe try and get to that in a future guide.
In fact, not only is it simple, there's actually a lot of inefficieny here, as I hadn't learned a lot of tricks yet, so I'll demonstrate at the end how we can shrink this a lot more. But for now, I think it's a good starting point to discuss some simple techniques.
Minification
First of all, perhaps the most obvious trick, the code has been minified by reducing all variables to a single character and removing all the whitespace (almost, I missed some here). There are tools which can do this, but personally I just do it by hand. Aside from me being too lazy to look up tools, this does have one advantage: You can often see ways to shave a few extra characters by changing the ordering of lines as you minify them.
Another related trick is assigning API functions to single character variable names. The assignment itself uses some extra characters, but if you're using the function more than once, this can end up saving characters overall. Whether it helps will depend on how long the function name is, and how often you're using it.
Here's an expanded version of the code that reverses this minification and adds some comments, so we can understand the rest of the code better:
-- initial riverbank values left=36 right=60 -- constants max_size=127 color_table={0,0,11,0,0,0,7,0,10,10,11,7} -- draw the sky, sun and grass cls(1) circfill(50,40,30,9) rectfill(0,50,max_size,max_size,3) -- draw the river for y=50,max_size do left=left+rnd(0.2) right=right+rnd(1) rectfill(left,y,right,y,12) end -- draw noise for i=1,max_size*left do x,y=rnd(max_size),rnd(max_size) c=pget(x,y) if i%c==0 then c=color_table[c] end rectfill(x-rnd(2),y,x+rnd(3),y,c) end |
A lot of this is fairly straightforward once it's expanded, so I'll just focus on some parts that might be a bit unusual.
Drawing the river
First, let's look at the river drawing section. This is fairly easy to understand, I just scan down the screen from the horizon, drawing horizontal blue lines as a river, and I have the left and right ends of the lines drift rightwards by a random amount each row to make it look organic.
for y=50,max_size do left=left+rnd(0.2) right=right+rnd(1) rectfill(left,y,right,y,12) end |
But there are a couple of things worth mentioning: Firstly, you may notice that I'm shifting the left and right positions by non-integer amounts. This works out fine, because PICO-8's graphics APIs will happily accept non-integer values and floors them for us automatically.
And one other peculiarity is that I'm drawing the horizontal lines with rectfill. Obviously this works, but why am I using it instead of line? Well, if you remember above, I had single character variable names for various API functions, and one of those was rectfill.
Originally, I had one call to rectfill and two calls to line. At first, I tried making a single character name for two calls line but that didn't help, so I didn't bother. But then I realised if I used rectfill I could use the same single character name for all three calls, and that helped quite a bit.
Adding noise
Now let's look at the noise section. You can perhaps see the overall structure here. We pick a bunch of random points on the screen, and then draw a short line based on it's colour. But there are perhaps a few things that seem a bit odd.
color_table={0,0,11,0,0,0,7,0,10,10,11,7} for i=1,max_size*left do x,y=rnd(max_size),rnd(max_size) c=pget(x,y) if i%c==0 then c=color_table[c] end rectfill(x-rnd(2),y,x+rnd(3),y,c) end |
Firstly, why is the size of the loop max_size*left? This is actually quite simple. I needed a number big enough to make the noise look good, and that turned out to be around a 5 digit number, so multiplying two variables ended up being shorter than a constant. Initially I just used m*m but after playing around for a bit I found m*l worked out nicer as it was a bit less intense.
Now let's look at the colour logic. The colour for the line to be drawn will sometimes just be the same as the pixel, which just creates a smearing effect, but sometimes I modify it using the color_table. A table is often a convenient and compact way to implement a simple mapping function.
One intersting note here: An earlier version of this table just assigned each of the previously drawn colours to a new one directly like so: {[1]=0,[3]=11,[9]=10,[12]=7} but this meant if we chose a pixel which already used one of the modified colours, it would end up with nil which draws as black. This didn't look terrible, but I thought it would look better without that. Initially, I thought it would be too expensive, as I'd have to add 4 extra cases to the table, but then I tried just expanding it to an array and it turned out to be only one character longer than the original!
{[1]=0,[3]=11,[9]=10,[12]=7} {0,0,11,0,0,0,7,0,10,10,11,7} |
This could also enable the possibility of using more complex colour mapping for different effects, but I didn't end up trying that.
Finally there's one last trick here that makes the resulting image look good, and I didn't even realise how neat it was when I first wrote it. The condition for using the alternative colour for the noise line is i%c==0. Initially I just used this because it seemed slightly shorter than something like rnd(5)<1 and had a similar effect. But then I realised that this actually varies the probability of a colour change for the different aspects of the scene! This gives the image a really nice sense of texture that I didn't expect, and I think that really contributes to the final effect.
Optimisation
Now, as I mentioned earlier as this was my first tweetcart, it's actually fairly inefficient. I recently went back to it with all the tricks I've learned since and managed to squash it down quite a bit. So I'll discuss some of those tricks here, to give you a headstart!
So here's the original version again, at 252 chars, as our starting point:
l,r,m,g,f=36,60,127,rnd,rectfill t={0,0,11,0,0,0,7,0,10,10,11,7} cls(1) circfill(50,40,30,9) f(0,50,m,m,3) for y=50,m do l=l+g(0.2) r=r+g(1) f(l,y,r,y,12) end for i=1,m*l do x,y=g(m),g(m) c=pget(x,y) if i%c==0 then c=t[c] end f(x-g(2),y,x+g(3),y,c) end |
Firstly, the minification I did missed out on a lot of whitespace that can be removed. It turns out that Lua's syntax actually allows a surprising number of things to run together on the same line! Assignments and function calls can all run directly after each other, as long as you don't end up with letters next to each other that combine into a single word. In my experience, you run into that most often with keywords like do and end.
Applying this, and stripping out unnecessary newlines takes us down to 242 chars very easily:
l,r,m,g,f=36,60,127,rnd,rectfill t={0,0,11,0,0,0,7,0,10,10,11,7}cls(1)circfill(50,40,30,9)f(0,50,m,m,3)for y=50,m do l=l+g(0.2)r=r+g(1)f(l,y,r,y,12)end for i=1,m*l do x,y=g(m),g(m)c=pget(x,y)if i%c==0 then c=t[c] end f(x-g(2),y,x+g(3),y,c)end |
Secondly, one really important feature of PICO-8's Lua that really helps in tweetcarts is the single line if statement. Rather than the full if/then/end block, we can also write if statements in the form: if(condition)dostuff. This can be applied to reduce the if statement above, although this does require us to move the if statement to its own line (I also noticed a way to shave a char off the condition). This takes us down to 233 chars:
l,r,m,g,f=36,60,127,rnd,rectfill t={0,0,11,0,0,0,7,0,10,10,11,7}cls(1)circfill(50,40,30,9)f(0,50,m,m,3)for y=50,m do l=l+g(0.2)r=r+g(1)f(l,y,r,y,12)end for i=1,m*l do x,y=g(m),g(m)c=pget(x,y) if(i%c<1)c=t[c] f(x-g(2),y,x+g(3),y,c)end |
Now let's look at those initialisations at the start. I put them all on the same line using Lua's multi-assignment syntax, as I had seen a few other tweetcarts do that, and assumed it was more efficient, but it's not actually necessary. It's actually the same size as just putting the statements on separate lines (newlines replaced with '/' to show this):
l,r,m,g,f=36,60,127,rnd,rectfill l=36/r=60/m=127/g=rnd/f=rectfill |
But we can do better than this, by running these lines together as well (including the x,y= assignment further down):
l=36r=60m=127g=rnd f=rectfill t={0,0,11,0,0,0,7,0,10,10,11,7}cls(1)circfill(50,40,30,9)f(0,50,m,m,3)for y=50,m do l=l+g(0.2)r=r+g(1)f(l,y,r,y,12)end for i=1,m*l do x=g(m)y=g(m)c=pget(x,y) if(i%c<1)c=t[c] f(x-g(2),y,x+g(3),y,c)end |
This isn't always possible as you can see from the f=rectfill on a separate line, but you can still save a few characters like this taking us down to 229.
We're getting down into really small savings here. One thing I tried at the time using the colour table inline, but it didn't work so I put it in a variable and left it at that. But it turns out if we put the table in parenthesis, it works and saves a whole... one character.
Finally, there's one more thing we can do here. Perhaps you already noticed it? Perhaps it's been bugging you for this entire guide? When I first wrote this tweetcart, I was used to regular Lua, and I didn't know that += and friends existed in PICO-8!
Applying both of these, we get down to 226:
l=36r=60m=127g=rnd f=rectfill cls(1)circfill(50,40,30,9)f(0,50,m,m,3)for y=50,m do l+=g(0.2)r+=g(1)f(l,y,r,y,12)end for i=1,m*l do x=g(m)y=g(m)c=pget(x,y) if(i%c<1)c=({0,0,11,0,0,0,7,0,10,10,11,7})[c] f(x-g(2),y,x+g(3),y,c)end |
That's all I can think of at the moment. It's quite likely I've forgotten something, but in any case with just a few tricks we've managed to save 26 characters! Hopefully knowing these tricks up front will save you from making the same mistakes as me!
Conclusion
And that's it! As you can see, there's not a huge amount going on in this sunset tweetcart. Once de-minified, it's a fairly simple to understand, and there are only really a few tricks here. Nonetheless, I'm quite proud of how nice it looks.
And this is perhaps an important point to end on: How effective a tweetcart demo looks usually ends up being a lot more dependent on working out a nice looking effect and exploring the creative possibilties, than it does on clever code-golfing tricks. As an example, consider this tweetcart I made a little later:
This was surprisingly difficuly to get right, because there's no built-in way to draw arcs with PICO-8, so it requires a careful combination of circles and clipping, so creating this took quite a bit of effort. It was a fun exercise, and I don't regret it, but nonetheless, as an effect I feel like it ended up looking fairly boring.
In contrast consider this tweetcart:
This is a really simple effect, and you can probably take a reasonable guess how it's done without thinking too hard. In fact, it's sufficiently simple that I probably wouldn't have even thought to try doing this if my girlfriend hadn't suggested it. Nonetheless, I've had more positive feedback for this than any of my others. So a good idea from my girlfriend made a much bigger difference than the few hours I spent trying to get those TV curves right!
So keep that in mind, and go and create your own tweetcarts. 🙂
This guide is super cool and helpful! Thanks for taking the time to break it down. This is might be a bit of an obvious question but how do you publish the final product? Like, how do you get the cart to run in a tweet?
@1gor you just post the source code as the tweet's text content, and attach a recorded gif of its result (hit F9 while it's running to save a gif of the previous 8 seconds to the desktop). If someone wants to "actually run it" then they copy-paste your code into a blank cartridge and run that (to get the same result you showed in your gif)
Are you painting the last pixel in each line of that star tweetcart a blue color?
@Lambdanaut
Yep, after it reaches a certain speed. I was originally trying for a red shift and blue shift effect, but the red didn't look as good so I got rid of it.
@PrincessChooChoo it's super satisfying. That little detail adds a lot. Great jorb and great tutorial! TY!
[Please log in to post a comment]