I want to show you some cool graphic effects you can achieve in PICO-8. It’s also a nice case-study for applying some functional programming techniques. What do I mean by graphic effects? Let’s take a look at some examples.
Text drawn with the regular print
function looks very plain. There are some things we can do to make it stand out more. We could add a drop shadow, or an outline. Or how about both?
Basic implementation
One way to code this is like this:
function print_outline(text,x,y,c,o)
for i=-1,1 do
for j=-1,1 do
print(text,x+i,y+j,o)
end
end
print(text,x,y,c)
end
function print_shadow(text,x,y,c,s)
print(text,x,y+1,s)
print(text,x,y,c)
end
function print_outline_shadow(text,x,y,c,o,s)
for i=-1,1 do
for j=-1,2 do
print(text,x+i,y+j,o)
end
end
print(text,x,y+1,s)
print(text,x,y,c)
end
-- usage:
function _draw()
cls(2)
print_outline("outline",50,54,7,14)
print_shadow("shadow",52,62,7,0)
print_outline_shadow("both",56,70,7,14,0)
end
This is fine, but I think we can do better. Conceptually, print_outline_shadow
is combining the effects of the other two functions. It would be nice if the structure of the code reflected this. Come to think of it, there’s another way to combine both effects. We could add an outline to text with a drop-shadow instead of adding a drop-shadow to outlined text. Right now the code doesn’t give us a good way to do that—we’d have to define a new function from scratch for print_shadow_outline
.
I’m going to take this code through some refactors to bring out the shared structure and make these effects composable.
Refactoring
For starters, I’m going to change the print
calls to use the current draw color instead of passing the color as an argument. This means adding calls to color
to setup the draw state.
function print_outline(text,x,y,c,o)
for i=-1,1 do
for j=-1,1 do
color(o)
print(text,x+i,y+j)
end
end
color(c)
print(text,x,y)
end
function print_shadow(text,x,y,c,s)
color(s)
print(text,x,y+1)
color(c)
print(text,x,y)
end
The next refactor I’ll make is similar. I’m going to use the camera
function to handle the offsets.
function print_outline(text,x,y,c,o)
for i=-1,1 do
for j=-1,1 do
camera(-i,-j)
color(o)
print(text,x,y)
end
end
color(c)
camera(0,0)
print(text,x,y)
end
function print_shadow(text,x,y,c,s)
camera(0,-1)
color(s)
print(text,x,y)
camera(0,0)
color(c)
print(text,x,y)
end
That was a lot of change with not much to show for it. The functions still behave the same, but the code is looking a lot worse! There was a purpose to all this though. All the print
calls are now exactly the same. That sets us up nicely for the next refactor.
Higher-order functions
This is where I’ll show of the first bit of functional programming knowhow. The print
call is now repeated in several places. What can we do to clean up a repeated section of code? Same thing we always do as programmers — store the repeated part in a variable and reuse it!
It’s important to take care while doing this. We can’t just say draw = print(text, x, y)
because we’re not interested in the return value of print
, we want to reuse its side-effect. We can achieve this by wrapping the print
call in a function.
function draw_with_outline(draw,c,o)
for i=-1,1 do
for j=-1,1 do
camera(-i,-j)
color(o)
draw()
end
end
color(c)
camera(0,0)
draw()
end
function draw_with_shadow(draw,c,s)
camera(0,-1)
color(s)
draw()
camera(0,0)
color(c)
draw()
end
-- usage:
function _draw()
cls(2)
local draw_text_1 = function()
print("outline",50,54)
end
local draw_text_2 = function()
print("shadow",52,62)
end
draw_with_outline(draw_text1, 7, 14)
draw_with_shadow(draw_text2, 7, 0)
end
Generality
Even though we started with text effects, the fact that we abstracted out the draw
function means this also works when drawing circles, rectangles, lines, etc. Cool!
function _draw()
cls(2)
-- both the text and the rectangle
-- are drawn with a shadow!
draw_with_shadow(function()
print("my cool game",42,60)
rect(32,32,96,96)
end, 7, 0)
end
It would be even cooler to do the same thing when drawing a sprite. Luckily, there’s not too much else we need to change to make that work. All we need to do is stop relying on the color
function, and use of pal
instead. pal
gives us control over the palette PICO-8 uses when drawing anything, in much the way that color
gives us control over the draw color PICO-8 uses for print
, circle
, etc.
function set_color(c)
-- set all 16 colors to 'c'
for i=0,15 do
pal(i,c)
end
end
function draw_with_outline(draw,o)
for i=-1,1 do
for j=-1,1 do
camera(-i,-j)
set_color(o)
draw()
end
end
set_color(c)
camera(0,0)
draw()
end
function draw_with_shadow(draw,c,s)
camera(0,-1)
set_color(s)
draw()
camera(0,0)
set_color(c)
draw()
end
-- usage:
function _draw()
cls(2)
draw_with_outline(function()
spr(1,64,64)
end, 7, 14)
end
Now we can apply an outline or drop-shadow effect when drawing sprites.
Suspended functions
There are more changes I want to make. I want to show how these effects can be composed together. To make that easier, instead of writing functions that we call to draw something to the screen, I’m going to switch to functions that return a “suspended” draw call. I’m also going to change the argument order, to put the draw function last.
We’ve already seen closures once before. I used a closure to extract the print
call earlier and pass it as an argument to draw_with_outline
. We can return a closure from a function too.
function draw_with_outline(c,o,draw)
return function()
for i=-1,1 do
for j=-1,1 do
camera(-i,-j)
color(o)
draw()
end
end
color(c)
camera(0,0)
draw()
end
end
function draw_with_shadow(c,s,draw)
return function()
camera(0,-1)
color(s)
draw()
camera(0,0)
color(c)
draw()
end
end
-- usage:
function _draw()
cls(2)
-- don't forget the extra `()` at the very end!
draw_with_outline(7,14,
draw_with_shadow(7,0,function()
print("outline",50,60)
end)
)()
end
There is an issue to watch out for now—it’s easy to forget the extra set of ()
needed to call the closure. I’ve forgotten it many times and wondered why on earth my sprites were never showing up.
Composing effects
The last refactor I want to do is to address the deeply nested code. Ironically, the way to address the nesting (in the usage), involves more nesting (in the library functions). Such is life. The extra level of nesting comes about by returning a partially applied or “curried” function.
function draw_with_outline(c,o)
return function(draw)
return function()
for i=-1,1 do
for j=-1,1 do
camera(-i,-j)
color(o)
draw()
end
end
color(c)
camera(0,0)
draw()
end
end
end
function draw_with_shadow(c,s)
return function(draw)
return function()
camera(0,-1)
color(s)
draw()
camera(0,0)
color(c)
draw()
end
end
end
Now we come to one of my favorite techniques. Since these effects are now functions that accept a draw function and return another draw function, they can be composed end-to-end. The combinators compose
and chain
do just that for a pair or a list of effects, respectively.
function id(x) return x end
function compose(f,g)
return function(x)
return g(f(x))
end
end
function chain(fs)
local ret = id
foreach(fs, function(f)
ret = compose(ret,f)
end)
return ret
end
-- usage:
function _draw()
-- a nice flat list of effects
-- no "extra" nesting required
chain({
draw_with_shadow(7,2),
draw_with_outline(7,14),
draw_with_shadow(7,0),
})
(function()
print("fancy text",44,60)
end)
()
end
Debugging and cleaning up
We’ve done the refactoring we need in order to make effects composable, but there are still a few minor details to work out. One issue is with the camera, and one is with the color palette.
Now that our implementation uses camera
, it might cause problems when our _draw()
function is already using camera
. It also causes problems when using multiple effects. It would be nicer if we took into account the current camera state when we use draw_with_shadow
or draw_with_outline
and put it back how it was when we’re done. We can do this by reading the camera state from memory and storing it in a variable. Similarly, let’s put the palette back how it was. We can also address a bug that happens when chanining effects — we want to override the palette only if we haven’t already changed it with another effect.
While I’m at it, I’ll also drop the argument for the initial color. Instead I’ll let PICO-8 use the current palette and draw color, which will handle the case when the sprite we want to draw uses multiple colors.
draw_color = nil
function draw_with_color(c)
return suspend(function(draw)
-- get the current palette state
local p1,p2,p3,p4 = peek4(0x5f00,4)
local prev = draw_color
-- set the draw color, but only if it hasn't already been changed
draw_color = prev or c
for i=0,15 do
pal(i,draw_color)
end
draw()
-- restore the palette
poke4(0x5f00,p1,p2,p3,p4)
draw_color = prev
end)
end
function draw_with_outline(o)
return function(draw)
return function()
-- get the current camera state
local x,y = peek2(0x5f28,2)
draw_with_color(o)(function()
for i=-1,1 do
for j=-1,1 do
camera(x-i,y-j)
draw()
end
end
end)()
-- restore the camera state
camera(x,y)
draw()
end
end
end
function draw_with_shadow(s)
return function(draw)
return function()
local x,y = peek2(0x5f28,2)
draw_with_color(s)(function()
camera(x,y-1)
draw()
end)()
camera(x,y)
draw()
end
end
end
Closing remarks
I hope I’ve passed along some useful functional programming knowhow, some PICO-8 tips, or maybe even both. If it’s useful to you, feel free to build on this code for your own projects. It’s something I use in a lot of my own carts.
If you’re not used to functional programming, these techniques can seem obtuse—they certainly felt that way to me when I was first learning them. Over time, I developed enough familiarity with them that they became second nature. I hope by showing the refactoring process step-by-step, I could help to de-mystify the way this code works.
I started out by saying I wanted to do “better” than the straightforward implementation. I certainly like my approach better. I like composable code, in particular because of how it makes it easy and fun to try different combinations, but I want to acknowledge that there are tradeoffs. My implemantation is a lot longer than the straightforward version, and not so straightforward to debug. If you’re trying to optimize your token count, this might not be the best approach.
Reference
Here’s a complete reference of the effects in this article, and a demo cart showing them off.
I’m licensing both under CC BY 4.0.
function id(x) return x end
function compose(f,g)
return function(x)
return g(f(x))
end
end
function chain(fs)
local ret = id
foreach(fs, function(f)
ret = compose(ret,f)
end)
return ret
end
draw_color = nil
function draw_with_color(c)
return function(draw)
return function()
-- get the current palette
local p1,p2,p3,p4 = peek4(0x5f00,4)
local prev = draw_color
draw_color = prev or c
for i=0,15 do
pal(i,draw_color)
end
draw()
-- restore the palette
poke4(0x5f00,p1,p2,p3,p4)
draw_color = prev
end
end
end
function draw_with_outline(o)
return function(draw)
return function()
-- get the current camera state
local x,y = peek2(0x5f28,2)
draw_with_color(o)(function()
for i=-1,1 do
for j=-1,1 do
camera(x-i,y-j)
draw()
end
end
end)()
-- restore the camera state
camera(x,y)
draw()
end
end
end
function draw_with_shadow(s)
return function(draw)
return function()
local x,y = peek2(0x5f28,2)
draw_with_color(s)(function()
camera(x,y-1)
draw()
end)()
camera(x,y)
draw()
end
end
end
![PICO-8 Cartridge](/_astro/suspend_demo.p8.DPZOcNJV.png)