OPTIMIZING CHARACTER COUNT FOR TWEETCARTS

(Eli Piilonen - 2DArray.net - @2DArray)

(You may host this on your website if you credit me!)

____________________________

INTRO CUTSCENE

Pico-8 is lovely, and tweetcarts are super fun, but let's say you want to fit some more shit into one of your 140- or 280-character demos. You'll need some tricks and shortcuts!

Here's the plan: We'll take the most recent tweetcart that I've made and break it down to see what kinds of weird code-compression is happening.

Sorry if some of this is super obvious stuff that you already know...I'm just gonna try to be comprehensive.

First, here's the final source code - if you have Pico-8, you can paste this into a blank file and run it: it's a (halfway-functional variant of) Tetris.

cls(9)p={204,108,46,78,142}r=0m=8192l=memcpy::_::flip()q=band

if(d!=0)x=64y=1s=rnd(5)+1l(0,m*3,m)d=0

y+=1l(m*3,0,m)b=btnp()x+=q(b,2)/2-q(b,1)r-=q(b,4)/16

for i=0,7 do

f=cos(r)g=sin(r)u=x+i%4*f-g*i/4v=y+i%4*g+f*i/4

if(p[s-s%1]/2^i%2>=1 and sget(u,v+1,pset(u,v,s))!=9)d=1

end

goto _

// (280 chars)

Here's a recording of the game:

https://twitter.com/2DArray/status/934234800319287296

Basically, the code looks like a bunch of horseshit, so we're gonna have to separate it out a bit.

____________________________

LEVEL ONE: LINE-BREAKS

The first trick I'd like to talk about is removing line-breaks. Linebreaks in LUA are often optional, so you can cut out several characters of most "traditionally-written" code.

For example, this:

cls()

p=5

...can be shortened into this...

cls()p=5

...and you save one character. I find that in an average tweetcart, you can generally do this at the very end of the process to remove about 10 or 20 characters (but your mileage may vary). Lines ending with parentheses or other special-characters are often the easiest to use for line-breaks, so it's good to try to end your lines with them.

x=sin(a)+y

// (Tough to chain!)

vs

x=y+sin(a)

// (Easy to chain!)

Now...I said the linebreaks are "often optional." Sometimes, removing a linebreak will combine two tokens into one:

end

goto _

If we try to combine these, we get "endgoto _" and we get a syntax error, so that's no good. Rearranging stuff can sometimes help here, but other times it might not be possible.

Here's a common situation when initializing your cart: You need to set a few variables to their default values.

a=5

z=10

r=0

You might try using the "comma notation" in LUA to set them all in one line:

a,z,r=5,10,0

...but unfortunately, this doesn't save any characters (still 13 total). Instead, we can just smash our original lines together:

a=5z=10r=0

LUA can parse this correctly, because it knows that each number is "done" if the next symbol is a letter - and so we've saved three characters worth of line breaks!

HOWEVER! Certain letters will break your code if you do this, because in certain cases, LUA can legally recognize numbers which have letters in them:

m=0x2000

The "0x" prefix means "this number is represented in hexadecimal." If you've used the recently-added fillp() function, you've likely also seen LUA's similar "0b" binary prefix:

p=0b1010010110100101

Because of this, you can't stick a variable called "b" right after a different variable's declaration. The "5b" in here will break your code:

a=5b=10

(I just pasted this into a blank cart - not only did it cause an error...it actually crashed the editor entirely in version 0.1.11d. Save your work often!)

There are some other letters that will also break when attached directly to a number, but honestly I don't know the full set. To fix the problem, you can either rename your variables to different letters until you find one that's accepted, or sometimes you can just change the order of your declaration (so the special-case character, like B, happens at the beginning of the line, or after some safe, happy parentheses).

So...there are lots of missing line-breaks in the Tetris cart. Let's see how it looks with all of those expanded out, like you'd see in normal, sane code. I'm also putting in some indentation for extra legibility:

cls(9)

p={204,108,46,78,142}

r=0

m=8192

l=memcpy

::_::

flip()

q=band

if(d!=0) x=64 y=1 s=rnd(5)+1 l(0,m*3,m) d=0

y+=1

l(m*3,0,m)

b=btnp()

x+=q(b,2)/2-q(b,1)

r-=q(b,4)/16

for i=0,7 do

f=cos(r)

g=sin(r)

u=x+i%4*f-g*i/4

v=y+i%4*g+f*i/4

if(p[s-s%1]/2^i%2>=1 and sget(u,v+1,pset(u,v,s))!=9)d=1

end

goto _

// (319 chars)

This is looking a bit more sensible now!

____________________________

LEVEL TWO: RENAMING FUNCTIONS

This one's easy, so we'll get through it quick. Some built-in functions in pico-8 have long names, and sometimes you use the same one more than once in your cart. In these cases, you can define a variable and assign the function to it, as a way to "rename" a long function:

s=sin

p=print

p(s(0))

p(s(.1))

p(s(.7))

p(s(.8))

If the function name is short and you don't use it much, you might not actually save any characters...so be sure to check your changes in a character-counter all the time.

____________________________

BONUS ROUND: BUILT-IN "PRINT" SHORTCUT

This one's not used in the Tetris cart, but it's super handy, and I don't think it's in the manual, so let's cover it real quick.

print("hello, tailor")

We can shorten this with a strange, generally inexplicable built-in shorthand:

?"hello, tailor"

When we do this, we have to put it on its own line, and its line can't be indented at all. Like the standard print() function, the shorthand can take multiple arguments (in the same order as usual: message, x, y, color):

?"hello, tailor",64,64,time()*8

____________________________

LEVEL THREE: IF-STATEMENTS

Here's the standard pattern for an if statement in LUA:

if (t()>5) then

print("hello, tailor")

end

// (43 chars)

There are several ways to shorten this. One simple option is to remove some spaces:

if(t()>5)then

print("hello, tailor")end

// (39 chars)

Now...I didn't learn this next bit for a long time, but the parentheses in LUA's if statements are optional (but I still generally use them anyway, out of force of habit). Luckily for us, the letter "T" isn't one of those "legal number characters," so we can do this:

if t()>5then

print("hello, tailor")end

// (38 chars)

Another option is to use LUA's "single-line if statement" shorthand - this one does require the parentheses around the conditional:

if(t()>5)print("hello, tailor")

// (31 chars)

This performs the same logic as the original version, but we've cut more than a quarter of our starting characters!

But what if we want to do more-than-one-thing when our if statement passes its check? For our if statement's block to contain multiple lines, it needs a "then" and "end" pair.

But don't worry! It turns out that we can just jam all of it onto one line by combining our "single-line-if-statement" trick with our "multiple-statements-on-one-line" trick:

if(t()>5)print("hello, tailor")print("and goodbye")

Any stuff after the if statement's parentheses (but on the same line) is all considered to be "inside the if-block," so either both print statements get called, or both print statements get skipped (never just-one-of-them). If you can combine it all into one line like this, you can still cut out your "then" and "end" statements!

Let's expand the first if statement in Tetris:

cls(9)

p={204,108,46,78,142}

r=0

m=8192

l=memcpy

::_::

flip()

q=band

if (d!=0) then

x=64

y=1

s=rnd(5)+1

l(0,m*3,m)

d=0

end

y+=1

l(m*3,0,m)

b=btnp()

x+=q(b,2)/2-q(b,1)

r-=q(b,4)/16

for i=0,7 do

f=cos(r)

g=sin(r)

u=x+i%4*f-g*i/4

v=y+i%4*g+f*i/4

if(p[s-s%1]/2^i%2>=1 and sget(u,v+1,pset(u,v,s))!=9)d=1

end

goto _

Even more sensible than before!

Now, let's look at that last if statement. Spoiler: It's a weird one.

if(p[s-s%1]/2^i%2>=1 and sget(u,v+1,pset(u,v,s))!=9)d=1

We're going to do this one backwards - we'll start with the standard (more legible) version, and then we'll work toward the final version.

if(p[s-s%1]/2^i%2>=1) then

pset(u,v,s)

if (sget(u,v+1)!=9) then

d=1

end

end

// (80 chars)

We can ignore that first conditional "somethingSpooky>=1" statement for now (we'll get to it later). For now, let's just try to shorten out our ifs and thens and ends. Like before, we can shorten the interior if statement by making it one line:

if(p[s-s%1]/2^i%2>=1) then

pset(u,v,s)

if(sget(u,v+1)!=9)d=1

end

// (66 chars)

Now...you might try to combine all of these lines into one giant one with the previous "shorthand if" trick. Unfortunately, trying it:

if(p[s-s%1]/2^i%2>=1)pset(u,v,s)if(sget(u,v+1)!=9)d=1

...gives us a syntax error ("'then' expected near 'd'"). Honestly, I don't know why, other than...uh, we pushed it too far. "If" statements are a bit sensitive when smashed together with other lines, particularly when skipping your then/end, and I don't know all of the rules...but I do know that the previous example doesn't run, so we need to do something else.

The thing that saves us here is a common compiler optimization related to AND statements.

if (A()==true AND B()==true) then

// do something

end

A quick note from Captain Obvious: An AND statement will only return true if both of its surrounding statements return true.

A quick note from Captain Not-Quite-As-Obvious: If the first check in the AND statement fails, the second one will not be executed at all, because no matter what it returns, the AND statement will still fail. This means we could combine our previous two layers of if statements into a single, unified line of hacky wonkery. Utility functions with no outputs can be included in our logical statement, as long as our logic is expecting those utility functions to return a nil value.

if(p[s-s%1]/2^i%2>=1 and(pset(u,v,s)or sget(u,v+1)!=9))d=1

// (58 chars)

At this point we've done pretty well! I've only got one more idea for this line, and it's a different way to include that pset() call - this is our utility function which returns nil.

Right now, we're trying to fit its nil output somewhere in our logic where it won't cause any problems, but we've got an extra OR statement and some parentheses that I want to remove.

(pset(u,v,s)or sget(u,v+1)!=9)

Instead of using our nil as a part of the logical statement, we can use it as an ignored function input.

sget(u,v+1,pset(u,v,s))!=9

This leaves us with the final line, as included in the cart:

if(p[s-s%1]/2^i%2>=1 and sget(u,v+1,pset(u,v,s))!=9)d=1

// (55 chars)

Woof!

Now that we've gotten through the formatting tricks, we can see the Tetris code fully-expanded - I'm also swapping out the function-nicknames for full names:

cls(9)

p={204,108,46,78,142}

r=0

m=8192

::_::

flip()

if (d!=0) then

x=64

y=1

s=rnd(5)+1

memcpy(0,m*3,m)

d=0

end

y+=1

memcpy(m*3,0,m)

b=btnp()

x+=band(b,2)/2-band(b,1)

r-=band(b,4)/16

for i=0,7 do

f=cos(r)

g=sin(r)

u=x+i%4*f-g*i/4

v=y+i%4*g+f*i/4

if(p[s-s%1]/2^i%2>=1) then

pset(u,v,s)

if (sget(u,v+1)!=9) then

d=1

end

end

end

goto _

// (377 chars)

____________________________

BONUS ROUND: MEMCPY AND YOU

There are two places where we call pico8's memcpy() function.

// initializing:

m=8192

// save screen to sprite sheet:

memcpy(0,m*3,m)

// paste sprite sheet to screen:

memcpy(m*3,0,m)

0x0 is the memory address of the sprite sheet in pico8, 0x6000 (or 8192*3 in base 10) is the screen buffer, and both of those fields have a size of 0x2000 (or 8192). That second memcpy() call is equivalent to this:

spr(0,0,0,16,16)

...but it's got less characters. Woo!

____________________________

BONUS-BONUS ROUND: HANDLING INPUT

Player input in Tweetjam-Tetris consists of three buttons: Tap to move left, tap to move right, and tap to rotate 90 degrees. Unlike real Tetris, there's no "drop" button, and instead the pieces just always fall really fast (but they also fall a very large distance to balance it out, or something).

Tweetjam-Tetris' trick for handling all of these came from Morgan McGuire (@CasualEffects on twitter):

q=band

b=btnp()

x+=q(b,2)/2-q(b,1)

r-=q(b,4)/16

More bitmasks and bitshifting! "b" stores a bitmask of all the buttons pressed, then we use some bitwise AND operations to separate out the bits and alter our position/rotation values. x changes by increments of 1, and r changes by increments of 1/4. Remember that band(b,4) will return either 0 or 4, so dividing by 16 gives us either 0 or 1/4.

I love this trick! The x+= line shows how you could easily extend this to handle 4-direction input for other games with just a few extra characters.

____________________________

BONUS-BONUS-BONUS ROUND: GAME LOOP SHORTCUT

One more quick trick that you may have already seen in a bunch of other carts:

::_::

// game loop

flip()goto _

You can also use a single letter instead of an underscore for your goto label, but for tweetcarts, I like how stupid and illegible it looks when you have ::_:: at the beginning of your code.

flip() makes the console wait for the end of the current 30fps frame. This goto method saves a few characters compared to the usual game loop in pico8, but it does the same thing:

function _update()

// game loop

end

____________________________

LEVEL FIVE: DEFINING TETRIS BLOCKS

Now that all the wacky syntax is out of the way, we can finally dial in on the stuff that's specific to this cartridge. First, let's figure out the way that the different piece shapes are stored.

Here's where we define all of the shapes:

p={204,108,46,78,142}

You might feel that something is wrong because that's just numbers and no shapes, or you might think something is wrong because it should be seven numbers instead of five.

If you're in the second camp, then congratulations! You're a step ahead of me, and you just won several internet points. (You can call Comcast to redeem your points for valuable prizes.)

The key to this list of numbers is that each item is a bitmask with 8 bits (or, "an integer from 0-255"). These 8 true/false values are interpreted as a 4x2 tilemap:

0010

1110

The "1" tiles in that tilemap form a sideways L shape. 00101110 in binary is the same as 46 in base 10, so we can save a bunch of characters by doing this conversion outside of our code and storing a 46 instead of something more legible.

This pre-conversion idea also applies when storing hex values: 0x2000 is a common hex value when dealing with pico8's memory layout, but if you store it as 8192 (the same number in base 10), you save a character. You'll never want to use the binary "0b" prefix in an optimized tweetcart - when doing a fillp() call, for example - because it's always going to be shorter to represent that number in base 10.

Okay, so we've defined five shapes out of the seven standard Tetris pieces, and we're just going to leave the other two out so we can save some characters. We'll skip the reverse-lightning-bolt, since it's, uh, relatively easy to forget about when the forward-lightning-bolt is present.

Then...we'll also skip the Line Piece.

I'm sorry. I'm so sorry. I know that this is Tetris sacrilege. The truth is, Tweetcart-Tetris doesn't even do line-clearing, since probably no one will play it for long enough to fill a whole line (I haven't even gotten close, myself). With this in mind, the line-piece isn't really as great and fun as it usually would be.

The second reason...is that if none of the pieces use the two far-right tiles in their 4x2 tilemap, then I can get away with slightly-less-accurate rotation code without breaking the pieces (and in doing so, save some characters).

Anyway, now we've defined our set of five pieces. How do we draw them?

____________________________

FINAL BOSS: DRAWING SHAPES

Here's our expanded version of the loop where we iterate through the bits in our current piece's 4x2 map:

for i=0,7 do

f=cos(r)

g=sin(r)

u=x+i%4*f-g*i/4

v=y+i%4*g+f*i/4

if (p[s-s%1]/2^i%2>=1) then

pset(u,v,s)

if (sget(u,v+1)!=9) then

d=1

end

end

end

f, g, u, and v are related to piece rotation - it's applying a standard 2D rotation matrix, where the input x value is i%4, and the input y value is i/4. This gives us our 2D index into our 4x2 tilemap - x is 0-3 and y is 0-1.

Really, the input y value should be flr(i/4) so it'd always be exactly 0 or 1, but as I mentioned before, being less accurate (and omitting the Line Piece) saves five characters here.

Ultimately, (u,v) is the current screen position where we're testing whether or not the falling piece has a block...which brings us back to this cluttered conditional statement:

if (p[s-s%1]/2^i%2>=1) then

First of all, let's remember that "p" is our list of shapes, and "i" is our 0-to-7 iterator. This leaves "s," which represents the index of our current piece.

...But like before, I want to get rid of flr() statements wherever possible, so s ends up being an important integer-piece-index, plus a random-and-meaningless fractional component. We can do "flr(s)" to drop the fractional part, but if we do "s-s%1" instead, we save one character. % is pretty common in tweetcarts, and pico8 in general, but just in case, it's the "remainder" (or "modulo") operator. s%1 gives us the fractional component (the remainder when we divide by 1), so subtracting it gives us just the integer component of s (which is a valid array index).

So let's look at the line again, swapping that p indexing stuff for a variable called "piece."

if (piece/2^i%2>=1) then

Now let's add some parenthesis to make the order of operations more clear:

if ( (piece/(2^i))%2 >= 1) then

Ultimately, this is another strange indexing operation. We have a bitmask with 8 bits, and we have an index to tell us which bit to check. We want to know whether or not that bit is 1.

This means we'll be doing some bit-shifting: We want to take a bitfield, move its items around, and ignore most of it.

Let's consider our L piece again:

0010

1110

Or:

00101110

Let's our bits are indexed from 0 to 7, bit 0 is on the far right, and our current index (i) is 0. We can check the far-right bit in two ways: The first is to perform a bitwise AND on the piece and the number 1.

if (band(piece,1)==1) then

Another option is to use the % operator:

if (piece%2==1) then

If we want to test any bit instead of just bit 0, we need to change our methods a bit:

if (band(piece,2^i)==1) then

This works because each bit's value is double that of the previous one (in the same way that in base 10, each digit is "ten times as powerful" as the previous).

We can do a similar thing with the modulo version:

if ((piece/(2^i))%2>=1) then

Since the order of operations is on our side this time, we don't need any grouping symbols:

if (piece/2^i%2>=1) then

Note that the % version uses "greater than or equal" instead of "exactly equal," like the band() version. This is because even though our modulus operation can remove all of the larger bits than the first one, pico8 only has one number type, which always includes fractional bits...so we're left with a bunch of straggling fractional-component bits to the right of our relevant ones-digit. We can ignore all of them by using the >= to say that "as long as we have the ones digit set to 1, we don't care what the fractional component is."

And there you have it - that's the most complicated part of this thing, I think. This wacky indexing happens at the beginning of the cart's final if statement, and then the second half of the if statement is drawing the current pixel and then checking if the pixel directly below it is already occupied. Here's the expanded version again:

if (p[s-s%1]/2^i%2>=1) then

pset(u,v,s)

if (sget(u,v+1)!=9) then

d=1

end

end

The 9 in the sget() line is the game's background color, and d being set to 1 means "the current piece has died." You can see at the beginning of the overall code that when d is 1, we save the screen to the sprite sheet, pick a new piece, and put it at the top-center of the screen.

____________________________

EPILOGUE

Okay, uh, so this turned out to be a lot longer than I expected. I hope you've learned something, and I hope to see some cool stuff out of you! Send me the shit you make! I want to see it!