andy

hello.

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:

tweet_2024-04-27_balatro.gif

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:

spinning_points_0.gif

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:

spinning_points_lines.gif

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.

time_graph_0.gif

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.

draw_exmaple

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:

line_draw_demo.gif

::_::
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)

draw_example_spinning

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:

card_back_abs_dist_0

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.

card_back_inverted.png

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:

card_back_anim.gif

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.

card_front.png

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.

diamond_divide.png

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 _

tweet_2024-04-27_balatro.gif

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.

(As you can probably guess, this post assumes some familiarity with the collectible card game Magic: The Gathering)

I just wrapped up a multi-week online Magic: The Gathering tournament that I've been wanting to do fo a while. The 3-Card Blind format is an interesting thought-exercise/meta-game challenge that has been played on forums since at least the early 2000s.

Instead of a normal game of Magic where players bring a deck of cards to face each other, in 3CB players submit a list of exactly 3 cards. All three of these cards start in hand and players do not lose for having no deck to draw from. All of the uncertainty, and even gameplay decisions, from a normal game are removed. The game is reduced to a sort of puzzle (or auto battler) where players try to come up with 3-card combinations that can win the game and even throw off opposing strategies.

some sample 3CB decks

In this post I'm going to talk about the rules of the format, what I did to make my tournament unique, and how you can run your own (including a template spreadsheet I created to make it easy for players to report their scores).

The main focus will be on the game design decisions I made to shape this particular tournament as well as the nuts-and-bolts of how it was run. Check out some of the links below for theory and strategy (which is fascinating in this format).

How It Works

Each week, players secretly submit their deck lists to an email account I made specifically for this purpose. I put their decks into a spreadsheet (preserved here), and at the end of each week, players would mark how they did against each other deck.

Scoring works like this: for each match, players play two games (one where they go first, one where the other player goes first). For each game they win, the player gets 3 points; for each where they tie they get 1 point; and they get 0 points for a loss. So the max number of points a player can get in a match is 6.

There are a few restrictions to keep things on track: * Decks cannot win on turn 1 * Decks cannot force an opponent to discard more than 2 cards * Random effects will always go in opponent's favor

The exact rules for my tournament can be found here on the page I used to introduce and maintain the event.

But how can participants play their games solo? This is the magic of this format: there is no randomness, players have perfect information & only 3 cards. Each game is more of a logic puzzle than a proper game. There is a “correct” sequence of plays, and it is possible to determine which deck comes out ahead (or if both decks stall out and tie). The MTG Fandom page linked below has more detail about edge cases or things that would introduce randomness, but the the take-away is that the skill is in submitting a well-positioned trio of cards, not in actually playing the games.

Example Game

Let's take an example from the last week of my tournament. We have the following 3 decks:

Player A: Thassa's Oracle, Cavern of Souls, Island

Thassa's Oracle, Cavern of Souls, Island

If unopposed, the Oracle deck will win on turn 2 (this was the “house deck” for the week. More on that in a minute). Not surprisingly, combo is weak to counterspells, so Cavern of Souls provides a little extra security.

Player B: Lupine Prototype, Mishra's Workshop, Trinisphere

Lupine Prototype, Mishra's Workshop, Trinisphere

Player B is looking to lock up the opponent with Trinisphere before playing a 5/5 that can end the game in 4 turns.

Player C: Blackmail, Cruel Sadist, Swamp

Blackmail, Cruel Sadist, Swamp

Player C is also playing a control angle, using Blackmail to pluck a critical card from the opponent and then taking their time with Cruel Sadist (which can hopefully get large enough to overcome an opposing blocker that manages to get around Blackmail).

How does it play out?

  • Player A vs Player B – On the play or on the draw, B can get Trinisphere out before A can play Thassa, completely locking them out since their 2 lands cannot produce 3 mana. (6 points for B, 0 points for A)

  • Player A vs Player C – Similarly, C can play Blackmail on turn 1, guaranteeing that they can pluck Thassa from the opponent. (6 points for C, 0 points for A)

  • Player B vs Player C – This one is interesting! When B goes first, they can get their Trinisphere down and prevent Blackmail or Cruel Sadist from being played. When C goes first, they can play blackmail and trash the Lupine Prototype. (3 points for each)

Although it tied in this example, the Blackmail/Cruel Sadist deck was the strongest against the field the week it was submitted. It was able to beat multiple decks that beat the Lupine Prototype deck.

All of these decks take advantage of the unique properties of the format to use cards that would otherwise be underpowered or at least require a lot more hoops to jump through. Thassa's Oracle is certainly powerful in its own right, but usually requires a lot more setup. Blackmail is not usually premium discard, but in this format it can hit every card in the opponent's hand (including lands). Lupine Prototype I had forgotten about entirely, but here it is a reliable 5/5 beater.

Pretty much every link below has other sample matchups that you can check out. These are filled with examples of cards that need to be re-evaluated in the context of 3CB. This is a format that really rewards players who dig for gold in older, mostly forgotten cards.

Resources

Before I dive further into my own event, I want to provide a few links that are helpful for anybody thinking about running a 3CB tournament or who just wants to read about this interesting offshoot of Magic. I certainly would not have been aware of 3CB or able to run my own tournament without them.

MTG Fandom Page – A solid primer and a good reference for the rules.

3-Card Magic by Brian Demars – This article on Channel Fireball was the first time I heard about the format.

Three Card Blind: A Whole Different Format by Goblinboy – A very detailed forum post from 2005 about the format. It's very fun to see the cards from that era evaluated for this format. Does a great job of pointing out cards that are unremarkable in normal Magic, but extremely strong in 3CB and vice versa.

3-Card Blind Metashape Tournament – This was a recent tournament run on Reddit by u/Lognu. My own tournament is heavily based on their setup. That link also has spreadsheets from the game and is a great resource to see decks that people have submitted.

My Tournament's Page – Here's where I laid out the rules for my tournament and maintained the ban-list.

My Tournament Spreadsheet – You can check out my decks submitted each week here! This was where the game took place. Player names have mostly been obscured and all comments have been removed (but they were lively!).

My Tournament

In terms of structure, I was heavily influenced by Lognu's Metashape Tournament. Rather than starting with an extensive ban list (as many 3CB games do), cards from the best performing decks are banned and the list evolves over time. This forces players to come up with new solutions each week.

A lot of the fun of this format is trying to beat the field. Although there are some combinations of cards that are quite strong, there is simply nothing that wins every match. So a huge part of the tournament is trying to figure out the meta and guess which decks other people will play. To this end I introduced one other major gameplay element: the house deck.

Each week, the highest performing deck has two cards banned. Then this deck becomes the house deck. In the following week it will be one of the decks that everybody faces. This is the only deck that players know they will face, so submitting a deck that beats it guarantees 6 points. However, all players know about this can can choose to design their own deck to prey on decks that will beat the house deck. The goal is to provide just enough known info about the next round for savvy players to predict the field. There is also the challenge of forcing players to beat the winning deck without having access to the same cards (since cards in the house deck have been banned).

During the course of the tournament, one player asked if Conspiracy cards could be used. This is a bit of an edge case since they aren't proper cards and are not typically available in constructed play, but I think it's fun when things get weird so I OKed them (with the exception of Power Play, because it undermined the structure of the tournament). I think it would be more than fair to ban them in your own tournament, but I liked the inclusion and they never felt like they broke things.

Ban List

My tournament ran for 5 weeks. In that time the following cards were banned:

Running The Game

Each round was given one week. Here is the cadence I listed in the game description:

  • Results will be announced on Monday, including the updates to the ban list.
  • Players have until Thursday 3PM EST to submit their decks.
  • Players should review their games by Saturday at 8PM.

Decks were submitted by people emailing their lists to a special email account I made for the purpose of this game. This meant that I had to submit my own deck before I officially opened submissions so that I did not see anybody else's deck before locking in my own.

Because players did not see each other's decks, I allowed players to update their decks until the Thursday deadline if they wanted to. I also tried to alert players if I noticed that their deck probably would not play out the way they expected it to, or if it broke some rule and would be disqualified.

On Thursday, a new page was made in the spreadsheet and players could start scoring their decks!

The spreadsheet

Much like Eve Online, this is a game played in spreadsheets.

I based my spreadsheet on the ones that Lognu made. The big difference is that in Lognu's game, players find their row and score themselves going right along each column. The decks are listed on the right, and I figured players know their own deck. So I switched this and had my players find their column and move down, allowing them to easily see the cards they faced in each match.

Here is a a gif I made of Jenny submitting her scores to explain the process to new players. (She has one win against Timmy for 3 points, and no wins against Spike)

Score submission example

You may notice that this system has duplicate information. For example, the result of cell E2 (Spike vs Timmy) can be derived from cell C4 (Timmy vs Spike). Since Timmy got a 0 against Spike, we know that Spike must have gotten a 6 against Timmy. But there is a reason for this: it means that every game has both players computing the results separately. Magic is a complex game and the optimal play is not always obvious. By having both players evaluate the games independently, there is some built in confirmation of the results. (This was further assisted by other players being generally interested. During the game, there was constant commentary on many of the cells as players discussed how games would play out).

Because I hated the thought of scanning the grid to determine if values did not line up, I wrote some formulas in my sheet to check if the values for a given pair of cells did not match. Then I used conditional formatting to highlight those cells in yellow to alert myself and other players that there was some issue here.

Let's take the same example from above. Let's say Spike shows up but thinks they win one game and tie one game against Timmy. grid with yellow highlight

The spreadsheet highlights the cells for that match in yellow. If one cell of a match is 0 (two losses), the other should be 6 (two wins). Alternatively, if one cell of a match is 4 (a win and a tie), the other should be 1 (a loss and a tie). The logic for this is frustratingly complex and I wrote a python script to export the formulas for the 225 cells needed for a 15 player game. If you are curious, you can check out rows 50 to 65 of my template sheet (they're hidden by default but you can expand them if you duplicate the sheet).

If you go through the weeks of my tournament, you'll find a few instances where I had to make a judge call (highlighted in purple), but in at least 90% of cases where the results did not match, players sorted it themselves (often with help from other players weighing in on the result via cell comments). Having the sheet automatically flag disputes did a lot encourage players to reevaluate and fix results. It also provided fun conversation fodder!

It is a credit to the group I was playing with that one of the most frequent disagreement was a player explaining why the other deck would actually beat them.

Week to Week

Dive into the spreadsheet if you want to see all the decks submitted each week. There are some very interesting trends as certain deck types had rises and falls in popularity. What I'm going to present here is the winning deck for each week (which became the house deck for the following week). It's a great microcosm of the strategies at play in 3CB.

Most players asked that I not include their names, but rest assured that none of these winning decks were submitted by me.

Week 1 – Inkmoth Nexus, The Tabernacle at Pendrell Vale, Urborg, Tomb of Yawgmoth Inkmoth Nexus, The Tabernacle at Pendrell Vale, Urborg, Tomb of YYawgmoth

Week 2 – Strip Mine, Chancellor of the Annex, Icatian Store (submitted by NaCl) Strip Mine, Chancellor of the Annex, Icatian Store (submitted by NaCl

Week 3 – Mesmeric Fiend, Leyline of Anticipation, Black Lotus Mesmeric Fiend, Leyline of Anticipation, Black Lotus

Week 4 – Memnite, Snapback, Force of Will Memnite, Snapback, Force of Will

Week 5 – Thassa's Oracle, Cavern of Souls, Island (submitted with only slight variations by 4 players) Thassa's Oracle, Cavern of Souls, Island

Week 6 – Blackmail, Cruel Sadist, Swamp (submitted by NaCl) Blackmail, Cruel Sadist, Swamp

You can see how each deck beats the one before it, but not necessarily the one two weeks prior!

Random Thoughts and Notes

Cell comments were an unexpected delight. I originally planned on including all participants on a group email each week to discuss the round, but players started adding comments directly onto the spreadsheet. It was very lively and became a space to discuss cool decks people had made in addition to sorting out tricky matches.

I do wish I had encouraged people to use burner email accounts and usernames. The comments in the spreadsheet are mostly tied to personal email accounts. It didn't occur to me that I might want to share the results of the game in a post like this, and at that point the comments were too linked to personal information. The spreadsheet I posted here has all of the decks, but the commentary and most of the names have been removed.

After one week of manually linking everybody's cards, I started asking players to submit their decks on a single line with each card name being a link to scryfall. It made the process of prepping the sheet much less time consuming.

If I did it again, I would green light conspiracy cards from the jump, as I thought they were a lot of fun.

In this game, I banned two cards from each winning deck (determined by the order in which they were submitted). This resulted in an interesting but slightly slow-moving ban list. In the future I'd ban all 3 cards in the winning deck and possibly take out one or two from other high scoring decks as well.

The house deck did exactly what I wanted it to do. It guided the field just enough that players could strategize around it.

Running Your Own / Using My Spreadsheet

If you are interested in running your own 3CB tournament, you absolutely should! It's a good time and you only need about 5 regular players to make it compelling.

You can use my or Lgnu's rules directly, but it's a lot of fun to make up your own spin on it as well.

If it is helpful, you can duplicate my spreadsheet template and get my nice conditional formatting.

If you do start a new game with this sheet, I'd love to hear about! Hit me up on twitter, mastodon or email me at andy[at]andymakes[dot]com.

While I have you here...

I'm a real sucker for games where people secretly submit their moves for the turn. I've been working on a wargame called Skirmish Society that was inspired by Diplomacy. It's played via Discord bot and you secretly DM your orders to the bot each turn (while plotting and making deals with other players in the public channel and over DM). I'm close to wrapping it up and would love to have a few more test groups. If that sounds fun to you, you can install it on your Discord for free over here!

This is adapted from a talk given by Ramsey and Andy at NYU Game Center on April 28th, 2022. Thanks to Jane Friedhoff for helping us adapt it to a blog post.

What is a technology co-op? Why would you want to form one? And how on earth do you go about doing it?

In this post, we’ll introduce you to the core of EMMA: how we created it, how we operate it as a group, and what needs it’s helping us fill that non-co-op work didn’t give us.

But first, a few disclaimers:

  • Every co-op is different and special, and we can only speak to our own experiences. Your mileage may vary! We can only tell you what we’ve done, and the particular path we took to get here. Think of this not as a guide, but rather an example.
  • EMMA is a service co-op, not a product co-op. This is an important distinction: a product-oriented co-op (say, a videogame co-op) or a co-op in a materials-heavy industry like construction will likely require much larger overhead and startup costs before they can recoup their initial investment. As a service co-op, our overhead is very low and our startup costs were minimal. This, plus decades of individual experience with the creative coding market as a whole, gives us a lot of freedom that other businesses might not have.

With that out of the way, let’s get started!

What is EMMA?

EMMA is a state-recognized software consultancy co-op located in New York State, which went official in January 2022. We write software for clients on a contractual basis, usually in the interactive software industry (e.g. videogames, public installations, VR/AR, web experiences) but we’re willing to take on work wherever we have expertise. Depending on who you ask, the name is either an homage to Emma Goldman (an amazing anarchist of the early 20th century), or an acronym: Everybody Making Money and Art.

Operationally, we use a consensus governance model with a proportional compensation structure: that is, we all make the decisions together as equals, and we get paid relative to how much we work. We only have one class of member: there’s no difference between founders and folks who might join later. We also rotate responsibilities, and very actively stay in touch with each other to make sure everyone understands how everything works and feels empowered to act on the co-op’s behalf.

Fiscally, members keep 70% of whatever client money they bring in, with 30% going to EMMA to cover costs and future salaries. If a member brings in less than a pre-agreed “base salary” due to a dry spell or life circumstance, they get that base salary.

Legally, we are a New York State Domestic Cooperative Corporation, also known as a Worker Cooperative. While we resemble a more conventional corporation, we’re not an LLC or S-Corp, for reasons we’ll go into later. We are all simultaneously owners and employees, and the same will be true of future hires.

Spiritually, we’re a group of weirdos who dream of spending our time making art, not grinding work.

Why EMMA?

Before we go into the logistical details of running your own co-op, let’s talk about how the four of us gravitated towards the idea of starting one.

All four of us–Ramsey, Andy, Gwen, and Ivan–have been in the creative code and technology world in different capacities for decades. Andy, for example, co-led an indie games company, left that to teach game design, and landed in creative technology consulting. Ramsey had likewise been freelancing in creative technology since 2010, in addition to his games, art, and research practices. Gwen moved directly from university into traditional salaried employment in the creative technology industry. However, she found herself freelancing in 2020-21 as a consequence of covid making temporary work more readily available. Ivan has been working professionally as a creative coder since 2005. He has been freelance for most of this time, taking breaks to lead creative technology teams and teach game and graphics programming.

Although all of us came from different work backgrounds, we discovered we had all quietly been looking for the same thing. Many of us came from arts and indie videogames, and found the constant hustling those fields required to get even scraps of funding to be exhausting and draining. The COVID-19 crisis highlighted just how much control our various employers had over our lives and safety: those of us who were teaching, for example, were given baffling demands that we return to office during outbreaks or lose our jobs (and health insurance). Ramsey counts the October 17 Revolution in his native Lebanon as a pivotal moment that made it clear to him that a better world was both possible and worth building right now.

In our earliest discussions, we found that we all wanted basically the same handful of things:

  • The possibility of making enough money to live comfortably, while having enough time to make art (making art is great, but paying rent with art is exhausting).
  • The security of having a predictable salary (tricky for a job as boom/bust as freelancing).
  • The agency to work in a way that aligns with our values (instead of those of some executive board or shareholders).
  • The satisfaction of knowing our money and labor is building a structure that we value (and supporting people that we value).
  • The opportunity to do interesting work if possible (we reject actively harmful work, but are also happy to be plumbers for digital toilets).

Non-cooperative structures could have provided us some of these things (with much less legal hassle) but with a variety of tradeoffs we did not want to make. We were lucky enough to have been exposed to quite a few other co-ops, some run by people we knew personally. Feel Train, for example, was a creative tech co-op from 2015-2019, with co-owners Darius Kazemi and Courtney Stanton sharing their bylaws and documents to the public (We strongly encourage you to check those out!). KO_OP, Soft Not Weak, and Motion Twin are all co-op game studios that also influenced our decision to go co-op. These were enterprises run by people we respected, making work we loved, and watching them do so while exploring more radical modes of labor was a huge inspiration.

There are a few lenses on how EMMA works: our internal conceptual model for the co-op; the logistical model of the co-op; and how this model was encoded into the legal structures afforded to us by the state. Let’s go into each.

How EMMA works, conceptually

What values guide our processes about and within EMMA?

EMMA is a financial capacitor

As we said before, EMMA uses a proportional compensation structure, meaning we get paid relative to how much work we bring into the co-op.

We see EMMA as a kind of financial capacitor. A capacitor is an electronic device that can store a charge of electricity and discharge it slowly, over time. This consistency is welcome in a field like freelance software development, which has booms and busts: periods of lots of work, and periods of no work at all. The idea of EMMA is to pool resources–to metaphorically charge the capacitor–during booms so that we can pay salaries during busts.

image showing the effect of a capacitor on voltage

This interdependence is a welcome support network for an erratic field. On our own, the chance of any one of us not having work for a given season is reasonably high. But if we all come together, the odds that absolutely no one has work are low. The capacitor is always getting charged by someone, which provides security for everyone.

EMMA supports our art practices

As you might imagine, the fiscal and even psychological security of having a guaranteed income stream makes it a lot easier for our members to have the time, energy, and resources, to pursue our own art. All four of us have artistic practices, and that is not a coincidence. A top-level goal of EMMA is to support the non-marketable art that its members make, and free them from having to depend on selling art (and the concurrent market forces around art) to survive.

EMMA minimizes work

Although EMMA’s business model is creative code consulting, we do not dream of labor! We want to work enough to live comfortably, and then stop when possible. This seems obscene in a world that expects constant exponential growth–but working less is an explicit goal of our co-op, as we want our members to be able to pursue projects that they find valuable for their own sake, as we said above.

Knowing when to stop working/start turning down work can be hard when you’re on your own: even if you make a lot, there’s always a future fallow period to worry about. But within the co-op structure, we can set common financial goals and identify when it’s financially safe to turn down work.

EMMA is a small node in a (hopefully) bigger mesh

Although we don’t have an exact number, EMMA will always remain small–likely fewer than 8 people. The governance of the organization can really only work with a small number of members: consensus decision making often doesn’t scale well beyond a small group.

Furthermore, it is critical to us that all members know and respect each other–not in a general be-nice-to-each-other way, but in a way that requires everyone knowing each other well and being invested in each others’ success. This also functions best with a small group.

And just because EMMA is small doesn’t mean that its dreams are! Our dream isn’t that we run the One Giant Co-op Everyone Joins: rather, we’d love to see a future of many, many co-ops self-managing themselves, tailored to their respective members’ needs. In the ultimate version of this, we would see cooperative workplaces collaborating with each other by sending delegates to industry syndicates to discuss and solve industry-wide issues. These syndicates would then send delegates to regional federations, with power and decision-making rising from the bottom up. This isn’t some thing we’ve conjured up, similar structures can be found in classic anarcho-syndicalist and more contemporary democratic confederalist theories and is being put into practice in places like the Autonomous Administration of North and East Syria. Being small is a feature, not a bug.

How EMMA works, logistically

So how does EMMA actually function day-to-day?

EMMA is run by consensus governance

When we say “everyone decides” you might assume that we either vote on fixed proposals or require unanimous decision-making. In actuality, we do neither. We treat decision making as a conversation, where a given proposal often changes over the course of the conversation, and where responses may fall on a spectrum from “I love it” to “this is OK” to “I hate this and I’ll leave over it.”

This is an unusual way for a corporation to operate, but is actually pretty straightforward in practice, and works well with a small group like ours: as Ivan says, “it takes you longer to make decisions, but the quality of the decisions tends to be better.”

This isn’t totally foreign to the tech industry, either. The IETF, which writes and maintains many load-bearing protocols that hold up the internet, is deeply committed to what they call a “rough consensus” process that avoids the will of a majority eclipsing the needs of the minority. If you ever wonder if this consensus stuff could actually work at scale consider the fact that you’re reading this blog post delivered to you over several IETF protocols as evidence that it does!

Spoiler alert: lawyers did not like this! “Infinite growth” is basically an axiom of modern business: all the structures around making one assume that the goal is to just keep scaling up forever. But our consensus governance model doesn’t scale all that well–doing it with four people is a lot easier than trying to do it with four hundred. We had a lot of back and forth with our lawyers that we understood and did not mind this!

EMMA uses proportional compensation

You also might assume that everyone is paid the same amount at EMMA, and this is not the case either. Everyone is paid the same way, but not necessarily the same amount.

Some co-ops do make it a point to pay everyone the same amount. It’s a perfectly valid approach, and we considered it at first, but we realized that it was not a good fit for EMMA. After all, different people have different life needs. Some may be happy to couch-surf, while others may be itching to buy a home. Some may have zero dependents, while others need to take care of family. Some may be keen to work absolutely as little as possible, while others may want to work more and build out their emergency funds. Additionally members will almost certainly wind up moving within that spectrum, and possibly in a big hurry! Having just one rate for everyone bulldozes over these various individual considerations and desires.

Rather, at EMMA, everyone is paid with the same method. Everyone keeps 70% of whatever they bring in, with 30% going to EMMA to cover operating costs and future salaries. In addition to that, as a safety net, if someone has a rough month and brings in less than a collectively agreed on base salary, no problem: they get paid the base salary anyway, thanks to our financial capacitor. This covers both dry spells and slow client payments. This structure allows people to work different amounts and come away with different amounts, which helps us support members regardless of their current life situation.

However, being paid different amounts doesn’t affect our governance. After all, a company could have a flat pay structure, while having an operational hierarchy. We have the opposite: while we make different amounts of money, each member always has the same decision making power within the co-op.

As a co-op we have access to certain kinds of corporate profit accounting that other legal entities do not. Specifically, we have at our disposal mechanisms to share EMMA’s end of year profit with members in novel ways. We have not enacted any of these mechanisms, however, as we have not been in business for more than a year. As of now the profit sharing plan is basically a spill-over. if 30% of EMMA's revenue is more money than we actually want to keep in the co-op we can use profit sharing to distribute that cash back to the members.

EMMA has one class of member, and everyone does everything

At EMMA, we have exactly one class of worker-owner: the “member.” There is no hierarchy among members, and when other members join down the line, they will be exactly equal to the founding members following a candidacy period.

It’s worth noting that not all co-ops are like this: it is not a legal requirement. The principles just call for “democratic member control,” which could allow for hierarchy. (Some larger co-ops have, for example, boards of directors and managers forming a more traditional looking corporate hierarchy. These positions are chosen democratically by the worker-owners, however). Given our small size and the political inclinations of some of our members we opted for a flat governance structure.

In addition to not having a hierarchy, we don’t want our members to get siloed into operational roles: we want everyone to do everything, at least a little. A practical example is our books. Every member of EMMA is trained to maintain the company’s books, and updates the relevant ledgers to reflect their own client work as well as general company transactions. Another is our payroll system: we rotate who actually operates the software and moves the money around, while the rest of us sit in on a video call to provide guidance and a double-check work.

This system of shared responsibilities has a lot of benefits. First and most pragmatically, this avoids the “hit by a bus” problem: if Ramsey is the only one who knows how to do the payroll software, but he gets hit by a bus, how will EMMA pay its employees in his absence? Much better to spread that knowledge out so that everyone feels competent at taking on a given role within the organization.

Second, this system makes our worker democracy more meaningful. Micheal Albert makes the argument that traditional division of labor in a cooperative runs the risk of eroding the collective’s democracy and creating a “coordinator class” of people who know how to run the enterprise at a higher level distinct from everyone else. For example, even if Ramsey didn’t get hit by a bus in the previous example, if he’s the only one who knows how payroll works, how can the co-op discuss payroll-related issues as equals? So we make it a point to share knowledge and make sure everyone gets their hands dirty in all aspects of running the ship.

How EMMA works, legally

How do we make sure the state isn’t mad at us?

First of all, we should disclaim yet again that there are many ways to approach incorporating a co-op. Some of our decisions were made to best fit our specific needs; some were made because we had certain freedoms that gave us more options (like having cash on hand to get set up); and some were likely mistakes we made because we didn’t know any better at the time! Talk to us in a few years when we have the benefit of hindsight.

All this just to say: the following isn’t a guide, and it is most definitely not legal advice, just an example of how we did things.

EMMA is a NYS Domestic Cooperative Corporation (Worker Cooperative)

On a technical level, we are a NYS Domestic Cooperative Corporation, or a Worker Cooperative. NYS has a legal entity that fits our needs and values so we went for it – this will be different in different states! We resemble a corporation, in that EMMA is a “legal person” that is taxed separately from us, but one where we are all owners and employees. We are compensated for our labor throughout the year and share in profits at the end of the year (there are co-op-specific mechanisms for this, although we have not had a full financial year to actually test any of them).

As a state-recognized co-op, we get some special benefits, as well as a handful of restraints that we actually really like.

New York State law requires us to act and govern ourselves as a co-op. This is both a restriction and a perk! This requirement makes it difficult to “stop being a co-op” if future members try and do that. This happens enough in co-ops that it has a name: demutualization. In S-corps and other similar entities that are set up to be co-ops via their bylaws alone, it is possible for the members to vote to demutualize at a later date. We want to make that as hard as possible.

And, of course, we get to legally be called a cooperative in our company name and get a .coop URL, which is pretty neat.

The main downside is the added complexity of setting up this corporate structure. Online incorporation platforms like LegalZoom do not have “cooperative” as an option, so you will need to engage some lawyers to draft and file your paperwork for you. There’s no reason a “LegalZoom for Cooperatives” could not exist, but figuring that out is an exercise left to the reader.

EMMA “totally has a hierarchy” ;) ;)

Remember our whole “no hierarchy” thing? Turns out that NYS requires every corporation to have a president, vice president, and treasurer. Boo! But rather than tangle with the law, we decided to get around this requirement by simply stripping those positions of any and all unique powers in our bylaws, and choosing them by dice roll. We will rotate positions each year, but we made sure that legally, they did not matter.

As part of our process, we also had to explicitly request one class of member, one class of stock, and so on. Our bylaws require all employees, owners, managers, officers, directors, shareholders, etc. to all be the same ‘person.’ Likewise, there is only one class of stock, and any member can only own a single share. Most of this was outside the default, and not obvious to the state/lawyers, so we had to explicitly request it.

How EMMA pays taxes

This part is speculative because we haven’t been incorporated for a full year at the time of this writing, but we intend to file our end of year corporate taxes as what’s called a “T-Corporation.” This is a corporation that files under the rules in subchapter T of the IRS code (the subchapter right after subchapter S that gives S-Corporations their name!) and files form 1120-C as opposed to the 1120 that conventional corporations file. Subchapter T and 1120-C are the cooperative specific tax rules we are subject to.

There are a few bells and whistles here in terms of how we’re allowed to retain and share profits, but given that we haven’t gotten this far yet we can’t really comment deeply on any of it. In theory, we can do a kind of end of year accounting unique to co-ops that might allow members to keep more of the money they made during the year compared to conventional forms of compensation. Also, we can more easily use EMMA as a “cash warehouse” meaning we can retain an unbounded amount of cash at the company without incurring extra taxes. This is important when we’re trying to keep that capacitor charged! Again, given that we have not been open long enough as of the time of this writing to have crossed a tax year boundary, this part of the structure is still speculative. Check back in with us in a year!

[Hello, 2023 EMMA here and we have some hindsight now. Much of this document is good, and the core goals and ideas laid out can work. However we've found that issuing a “commission” is a specific stumbling block both legally and tax-wise. Our best advice is to consult with Lawyers and CPAs early and often!]

EMMA uses professionals to help manage all this

There is no way we could have done all this on our own. The process of incorporating and maintaining our money requires people with expertise we simply don’t have!

By and large, we tried to stick with organizations that aligned in some way with our values. This mostly meant working with other co-ops. There was typically a trade-off in convenience and accessibility, but it was nice to work with folks who not only shared our political goals, but who also tended to have a better understanding of what a co-op even is.

Our legal counsel was provided by JL Weiner & Associates whom we liked working with, and would recommend. We bank with Brooklyn Co-op FCU, got bookkeeping guidance from A Bookkeeping Cooperative, and used Gusto to run payroll.

Benefits we get of being part of a co-op

We’ve already mentioned some clear benefits of being part of a co-op–the financial capacitor aspect being a key one. For a while, it seemed like that was the primary thing we’d get out of it. And don’t get us wrong–that’s plenty great on its own! But even long before we were officially incorporated, we started seeing a lot of other benefits too.

It provides mutual support and a framework for knowledge-sharing

Work is hard! Freelancing involves a lot of interpersonal and operational skills that can be overwhelming: everything from doing taxes to dealing with stubborn clients to negotiating rates. And if you work in a field where things are constantly changing–like ours, where new technology is getting introduced, frameworks are getting changed, and software updates are constantly wrecking past projects–it can be very stressful to take on a new project solo. Having a team of people away from clients to lean on when things are confusing is invaluable.

This mutual support really comes in handy when we run into the kinds of technical issues mentioned above. We’re all creative coders, but even so, we all have different skills and specialties. Each of our members is an expert in at least one thing that the others know very little about. Our tech support channel in our self-hosted MatterMost (an open source Slack clone) is a wildly helpful resource whenever any of us run into issues on the job.

Furthermore, this knowledge-sharing doesn’t end when the gig does. While we come to each other to solve specific problems, we also find that the co-op structure encourages genuine ongoing learning and mentorship.

It makes it easy to share gigs

If you’re freelancing during a boom period, you may get solicited for more work than you can take on. It takes a bit of time and effort to reply to a potential client with a curated list of other people who might be able to do the gig well. But with a co-op structure in place, it’s as easy as saying, “I am booked, but let me check with my cooperative!” and passing the work on to them. Indeed, we’ve already shared gigs and bookings (at the time of this writing, we’re all booked on the same client!). And this added income supports all of us in the end.

This sets us up as a kind of stable of experts who can be brought on for a client’s project as needed. Ivan compares it to going to your favorite barber shop as a walk-in: your absolute favorite barber might be busy, but you know that everyone working there is good.

It gives us better negotiating power

A lot of freelancing stress comes from money (surprise!). As individuals, we’ve all needed and given advice to others who are struggling to set their rates, or trying to write an email gently reminding a sluggish client to pay.

But as a co-op, it’s easier to ask for money, and easier to get that money. We’ve already used ‘everyone@emma.coop’ to remind clients of a late invoice to very positive effect. This faceless collective email account helps avoid the discomfort of personal client confrontation.

Further, we are able to bounce rates off each other, and treat rates as standard across our co-op. This is doubly important given the many pay inequality issues in tech (across race, gender, etc.). When we work separately, and when wage information is effectively secret, it’s easy for clients to nickel and dime us individually. But when we share our rates and bargain together, we can provide a unified front for more fair compensation for everyone. A rising tide lifts all boats!

Advice for starting your own co-op

Hopefully, we’ve given you some practical insights into how you might conceive of, form, and run your own co-op. But before we sign off, a few pieces of advice:

Co-op structures do not guarantee success

Hopefully obvious, but being a co-op does not on its own guarantee success. EMMA is doing well right now, but that’s in part thanks to the fact that we were all doing well as freelancers before EMMA. We were able to bring professional networks built over decades to the co-op, and that’s certainly bolstered our current business. We don’t want to give the impression that adopting a cooperative structure is a guaranteed recipe for financial success! You still have to succeed as a business in conventional ways.

You can test drive a co-op today!

Before we were a state-sanctioned cooperative corporation, we spent about a year unofficially acting like one. We had weekly meetings hosted on a friend’s personal Slack, where we’d talk about work and hold each other accountable to our various career and artistic goals. This regular communication made it easy and convenient to share gigs, give advice, and help with rate negotiation, allowing us to build solidarity by lifting each other up. A year of doing this gave us a strong working relationship, and an investment in each other’s success and happiness long before any paperwork was filed.

The nice thing about this is that you can do it today, no government forms required! If you’re interested in starting a co-op, try building a habit of normal meetings with your desired collaborators, and creating platforms where you can share your advice/expertise/networks with each other. Even if you don’t end up starting a co-op, you’ll likely find that you get a lot of benefits having this space to work together rather than apart.

Say hi!

We hope this was helpful to you! If you want to see what we’re up to you can check out emma.coop (love that .coop domain) or follow us on social media: https://twitter.com/emmacooperative https://www.instagram.com/emma.cooperative/

Resources

When we gave this talk at NYU, we closed by linking to these resources that were mentioned in the lecture. They helped us and hopefully they can help you too!

institute.coop — Democracy at Work, resources on forming and running a co-op

feeltrain.com/blog/operating-agreement/ — Feel Train documents

Earlier this week I released Pico-Pond, a networked PICO-8 demake of Frog Chorus, a web-project I love by v buckenham and Viviane Schwarz.

You can play Pico-Pond for free in your browser here: https://andymakes.itch.io/pico-pond

You can also look through the source code, which I hope will be helpful for anybody else looking to make a networked PICO-8 game. The code is commented and (hopefully) easily readable.

Please keep in mind that this was a weekend project that was never meant to scale beyond 20 players. I did not try to make it super efficient or account for every edge case.

Overview

screenshot from the game

Just like in the original, you control a single frog in a pond populated by other real people. All you can do is hold a button to grow in size and then release it to make a ribbit. The ribbit is louder and longer the bigger your frog is when you release. All credit for this design goes to buckenham and Schwarz. It is deceptively simple, but the act of communicating with strangers this way is surprisingly rich.

What makes Pico-Pond somewhat unusual for PICO-8 is that it is networked! This is not a standard feature of PICO-8, but is possible in a web build through some fairly straightforward hacks. I've been curious about exploring this for a while after reading about some early work in this direction. I selected a Frog Chorus tribute both because I loved the original and because the simplicity of it lent itself to a good first project.

Before I get into the details, here's an overview of how this project connects multiple users into the same PICO-8 experience. The magic is in the gpio pins added in version 0.1.7. The gpio pins represent a set of 128 bytes of memory and were designed for PICO-8 projects on the raspi to allow games to be able to respond to hardware on the device. For whatever reason, they are also accessible to JavaScript running alongside the game in web builds. This can bridge communication between the PICO-8 game and the surrounding web page. Once you've broken out into JS, the sky is the limit.

This particular project uses a setup like this: The gpio pins are used to allow my JS and the PICO-8 game to communicate. My frontend JS then communicates to a node server hosted on Heroku using a websocket.

flow diagram

JS / PICO-8 Communication

The first time I read about doing something like this was a few years ago when I stumbled across this post by edo999. This used JavaScript equivalents of the PICO-8 peek & poke functions to insert or read values from the heap. Seemed promising and I kept it in my back pocket as something I wanted to check out. More recently I encountered seleb's post about a functional twitter client he wrote in PICO-8 (it's really cool. Check it out!). He was using the gpio method and was kind enough to include his source code.

This method is very easy! For the JS file, all you need to do is create an array of 128 elements called pico8_gpio. After exporting the PICO-8 game as a web page, the javascript file needs to be included in the HTML file. The 128 elements of this array map to 128 locations in the PICO-8 memory starting at location 0x5f80. To write to slot 2 and read from slot 5 in JavaScript I might write

pico8_gpio[2] = 32;
let my_val = pico8_gpio[5];

And to do the same in PICO-8 I would use peek & poke to manipulate memory

poke(0x5f80+2, 32)
local my_val = peek(0x5f80+5)

That's pretty much it! One tricky thing is that this only works when running a web build, so testing in the editor becomes tricky. I wound up writing some testing values to memory in _init() so that my app thought it was connected. Needing to make a web build definitely increases debug time.

Managing Communication

There are 128 gpio pins to use! As seleb notes in his post, “128 bytes is a pretty big chunk of data for a PICO-8 app.” Pico-Pond doesn't even come close to using all of them. Seleb came up with a clever structure to allow the JS and PICO-8 elements use the same pins in order to send large amount of data, with the first pin acting as a control pin letting both apps know whose turn it was. Luckily my needs were simpler so instead every pin is designated as being for a specific app. Either the JS script writes to it and PICO-8 reads, or the other way around. They never write to the same pin.

One note is that although I could store negative numbers in these addresses from PICO-8, I was not able to write a negative value from JS. So some things might seem a little odd (like using 200 to mean that a frog is not active). I'm sure this can easily be fixed, but it was never enough of an issue for this game.

Here's the breakdown of pins in the finished project. I didn't settle on 20 frogs right away and I wasn't sure how many pins I would need for each frog, so I left the first 100 pins for game-state information from the backend and started additional data at pin 100.

PIN number Description Who writes to it Use
1-20 Frog Values JS values of all frogs in range from 0-100. 200 means that frog is unused
21-99 unused unused unused
100 Player Input PICO-8 1 if button held, 0 if not
101 ID JS ID of this player. Set by backend
102 Cart Start PICO-8 starts at 0, set to 1 when cart is loaded to let JS know it's ready
103 Status JS Tells the game the status of the connection to the backend (waiting, connected, room full, disconnected/error)

Pin 0 wound up being unused when I moved all the frog values up by 1 pin to match PICO-8/Lua style of indexing arrays at 1 (a constant source of consternation).

The values in 1-20 and 101 get set from the backend, but not directly. The JS script communicates with the backend via the websocket. There is a regular pulse from the backend with the gamestate. When the frontend receives this message, it writes the values to those 20 pins. Likewise, when the backend responds to a new player with a frog ID, the frontend JS writes that ID to pin 101.

The JS script does not manage game logic at all. It just acts as a bridge between PICO-8 and the backend.

Keeping the Data Small

You might notice that each frog in the game is stored as a single number between 0 and 100 (with 200 meaning they are inactive). Although I didn't think I would even come close to using all 128 pins, I wanted to keep the amount of data moving from the backend as small as possible (mostly so that I could easily send the entire gamestate instead of needing to have a more clever system).

One way to approach a game like this would be to have the backend create a frog when a user connects. Things like the X and Y, the color etc of the frog could be generated and stored in the backend. This would be fine, but then all of those attributes need to be sent to clients who connect to the game. In this game the only thing that changes is the size of the frog (increasing when the button is held and then returning to 0 when released), so I wanted that to be the only value managed by the backend. I think an argument could be made for just having the backend track if a player is holding down the button or not and letting the PICO-8 game manage the rising and falling value, but I wanted the backend to have an accurate snapshot of the current gamestate so that a newly connected player would have all of the correct data.

Right now, when a new player joins, the backend finds a random open frog and sends them the ID of that frog. The frog object on the backend consists of a number value and a boolean for if the player is holding the button. Every tick (targeting 30FPS to match PICO-8) the frog value goes up if the button is held and down if it is released. Changes in the user's button state are sent via websocket whenever the frontend JS sees that the input pin has changed value.

So what happens to those other attributes? They don't really have to be consistent; they could be randomized when the game starts, but that doesn't feel right. If I'm the red frog in the bottom left corner, it feels like I should be that frog for everybody.

The easily solution here is to randomize these things, but to seed the random number generator so that the values are the same for everybody. I wrote a simple (and very inefficient (please don't @ me if you read the source code)) function to randomly distribute the positions of the frogs to roughly fill out the screen and then randomized the other frog attributes (color, sound etc). For each of these steps I tried out a few different seeds until I found ones I like. Now all instances of the game have the same pond without needing to send those extra values across the wire.

Audio

The last thing I needed to do was make the frogs ribbit. One of the charming things about Frog Chorus is that the different frogs have different voices (audio recordings of people saying “ribbit”) that get played at different speeds and volumes depending on how big the frog is when you release. The fact that the frogs sound different from each other and that the sound is modulated by how long the button was held contribute in a big way to the conversational feel of Frog Chorus. You can get into a groove with people where you do lots of little chirps or each grow really big to do a loud ribbit at the same time; it's a lot of fun! I wanted to make sure that this aspect was captured as well.

As such, one-shot sound effects were probably not going to cover it. Luckily there are some tools I know from making hyper-condensed tweet jam games that allow for some dynamic audio generation. Weirdly, the print command can be fed a special character that causes the string that follows to be played as audio. This whole portion of the PICO-8 documentation on control codes is pretty interesting.

The basic format is “\ab..e#..b” where “\a” is the control code that signals to PICO-8 to treat the string as audio and then everything after that is telling it what to play—in this case a B and E# and another B with a slight pause in between each note.

There are some additional properties available such as volume (v), instrument (i), effect (x), and speed (s).

For example let's look at “\as9v7x3i0f..d-..f”. Going from the end and working our way to the left this string plays an F note followed by D flat then another F (“f..d-..f”) using instrument 0 (“i0”), effect 3 (“x3”) at volume 7 (“v7”) and speed 9 (“s9”).

Not very pretty to look at but it gives a lot of control.

Speed and volume are fantastic for modulating the sound. I mapped the size of the frog when the button is released to these values so that big frogs play a slow, loud sound and little frogs play quick fast ones. After some trial and error, I got it so the length of the sound roughly matches the length of time it takes the frog to shrink back down to the base size.

I'm sure a sound effects person could do a better job, but I got a fairly nice ribbit-y trill by going back and forth between two notes like this: high note, low note, high note, low note. These notes are defined for each frog in the setup function using the same seeded random function as the other attributes.

Then, to give frogs their own voice, I also randomize the instruments and effects. Not all of them sound froggy, but I tried each one and made a pool of acceptable options for each frog to pull from in setup.

If there are tons of frogs in the pond, these sounds are likely to get cutoff when multiple frogs use the same instrument. I think that's OK though because that means there are enough people using it that it will feel lively anyway.

Wrap Up

There's a bit more going on, but those are the exciting parts! Please dig into the code if you're curious. Once I was finished I went over it and commented everything to try and make it as easy as possible to parse.

I hope this is helpful if you want to add a bit of networking to your own PICO-8 projects!

Frog Chorus was made by v buckenham & Viviane Schwarz.

The frog pic used in the game is a lightly modified version of an image made by scofanogd and released with a CC0 license.

Thanks to Sean S. LeBlanc for the breakdown of his PICO-8 twitter client, which was a key part of getting this off the ground!

Thanks to lexaloffle for PICO-8!