(click to expand)
1024 bytes is an interesting size limit for a game; it's large enough to try for some relatively detailed game logic or visuals, but small enough that you need to execute some weird programming tricks and design pivots to get everything to fit. If you found this post because you heard that PICO-8 is a nice introduction to game programming, I apologize for the following code snippets! First up, here is the full source code for the game that includes the graphics and map data. It can be pasted into PICO-8 0.2.3 or later:
➡️=0t=8⬇️=7for x=0,3800do ➡️-=1if(➡️<1)➡️=2r=3⬇️=7-⬇️ while r>2do r=ord("○ュ?\0゜ョヤャ◝◝⁷ツん¹ュ=◝に◝+ンョャヤ○る³ユ\n <◝エ𝘤ムもにュよ◝トラ⁙⁴よ░²リムリᵇ³ト■𝘨○ワ;◝エらョ0\0pクシチ◜◝=ュoれ/ウワ◝モャいユ𝘪ゆ?=0◝cりdオトは⁷ト?⁵\0𝘴リ◆○☉6サ¹6ア?ュ◝❎。。ユユ◝ャヤ2v\0q○¹\0ワw3v\0001よ¹\0◝?𝘰v⁴ャ゜⁴ら█pタp⁵ュ?オ; <○○ヨ◝oに◝𝘰ュ?1░ろヲア◝ᵇp○\0s³ヲュひ\0|れツ◝リツ𝘰゜6ト⁴ュ?p◜◝モpᶜオん▶○◜ャ\r○𝘣◝▶トリに1◝⁷ョ_ンん゜ョょ737⬇️○ョ¹ュのら◜-エs○ユト⁴らメ\0³•~◝エろヨ/゜3\0な?𝘯ュ◝よ▮ラヨsクろ?リトンミエセろ⁵ᵇムり3オ◝𝘮rユ⁷ワ>pンᶠ゜シてン>ナレ◝𝘰⁙ュuワ◝シ◝1゛ュ4チ◝?◝◝𝘰\0゜ᵇユ◝エ◝4◀0○ ◝◝◝◝◝\0ら𝘬\0",t\8)>>t%8&3t+=2➡️+=r end sset(x%95,x\95,⬇️)end o=128w=256f=0r=4128🐱=cos ➡️=0 ❎=64🅾️=r*2s=spr::_::t+=.03camera(❎,🅾️)map()v=0for i=0,t/2do x=i\32y=i%32v/=2v+=sget(r%o+x/4,r\o+y/4-🐱(i/870)/2)&4mset(x,y,v)p=i%4y=i\4*5%31 if(mget(x,y+1)==4)s(0,x*8+🐱(p/4-t),y*8-p*4) if(r%5==2and i<o)s(i&2,-t*i%w,i*i%w+🐱(i/9+t)*3)➡️-=1>>12 end if(r==4136)s(8,92,112,5,5) s(20,o+🐱(t/2)*9,90+r+🐱(t)*5,1.5,2)b=btn()n=b&32f=f/2+n/20k=b%4\2-b%2if(f>n)➡️+=f*k ⬇️-=f f=0 ❎+=➡️ 🅾️+=⬇️ ➡️=-➡️ ⬇️=-⬇️?"ᶜe\^wfin ♥",108,60+r if(mget(❎/4,🅾️/4)<1)➡️*=-.95⬇️=.05-⬇️*.9 r+=(❎\o+🅾️\o*o)*8❎%=o 🅾️%=o s(16+(f&2)+k%2*32,❎*2-8,🅾️*2-8,2,2,k<0)?"⁶1⁶c" goto _
Here's the 95x40 spritesheet (hidden for spoilers).
The sprites and map data is stored in the long string passed to the ord() function. The challenge was to compress the data in a way that the decoder + the decompressed data would be smaller than the raw input data. This is hard to do with a small amount of 1-bit graphics!
The format we settled on quite early is a variant of RLE (Runtime Length Encoding -- storing the lengths of spans of the same colour) with an interesting constraint: the smallest span length must be 2 pixels. This means that it is impossible to have a single-pixel vertical line, but thin horizontal lines are ok. It is also quite different from using double-width pixels, as spans can stop and start anywhere.
Each span length is stored as a sequence of 2-bit values. Each value is added to the span length, and the sequence is finished when the value is not 0b11 (3). This means that that 2-pixel spans take 2 bits to store (the worse case -- same as raw), 4-pixel spans also take 2 bits (the best case -- half the data needed), and for very long spans the data compresses to around 2/3 of the original size. The number of bits needs to store a span length jumps up every 3 values:
2 pixel span: 00 3 pixel span: 01 4 pixel span: 10 5 pixel span: 11 00 <- 2 more bits because the first value is 0b11 6 pixel span: 11 01 7 pixel span: 11 10 8 pixel span: 11 11 00 ...
The code for the decoder is 106 characters after re-using some game state variables, and the sprite data compresses to 340 bytes, which becomes 358 characters with the required escape codes to store it in the source code (e.g. value 0 becomes "\0"). This gives a total of 464 characters -- around 50 less than using raw data + some code to transfer it to the sprite sheet. Worth it!
-- decode sprite data v=0 -- last read 2-bit value l=0 -- length of span i=8 -- input data position in bits c=7 -- current span colour for x=0,3800 do -- for each spritesheet pixel l-=1 -- one less pixel left to draw of the current span -- read the next span length (l) if ran out of pixels to write -- ord() grabs an 8-bit value from the data string if(l<1) l=2 r=3 c=7-c while r>2 do r=ord(data_string,i\8)>>i%8&3 i+=2 l+=r end pset(x%95,x\95,c) -- set a single pixel end
To stretch out the limited sprite data as map data, each pixel in the spritesheet corresponds to a 4x4 block of map tiles. To prevent the world from looking too blocky, the sampling position is distorted by a sine wave that is chosen to line up between neighboring rooms, so that it is not possible to enter the next room and already be inside a wall.
It is also possible to reuse the structure of sprites as map shapes. This only ended up happening once: the left side of the last tunnel is actually the sprite data of the seahorse. But if you're really keen, you can also glitch through a wall to get out of bounds, and then explore the spritesheet.
When making code-golfed games and tweetcarts, I normally end up having a single large loop that is used by anything that needs to be looped, to avoid the "FOR .. DO .. END" 16-character overhead. Unfortunately (or fortunately) I was unable to merge the sprite unpacking in this way, but the rest of the game uses a single superloop. The map data is fetched from the spritesheet, bubbles are drawn, and segments of seaweed are drawn at random top-of-ground locations, all using the same loop counter.
-- time starts around 2722, and t/2 is cheaper than writing a 4-digit number for i=0,t/2 do -- unpack map from the spritesheet in 4x4 map tiles -- Using v = (sget() + v)/2 gives tile values that differ -- based on their vertical neighbour so that viable seaweed -- locations can be identified (top is always 4) x=i\32y=i%32 v/=2 v+=sget(r%o+x/4,r\o+y/4-sin(i/870)/2)&4 mset(x,y,v) -- seaweed p=i%4 -- which segment of seaweed y=i\4*5%31 -- y position of seaweed to test (unevenly scattered) if(mget(x,y+1)==4)spr(0,x*8+sin(p/4-t),y*8-p*4) -- draw bubbes and apply water current to player -- only for 1/5 rooms, and for the first 128 iterations if(r%5==2and i<128)spr(i&2,-t*i%w,i*i%w+cos(i/9+t)*3)player_dx-=1>>12 end
The player uses a coordinate system (0..128 in each room) that is rigged so that the world position of the player is the same as the screen position after adjusting for camera position and scrolling. It also gives nice ranges of values that can be manipulated with expressions containing only small integer values.
-- control player -- accumulate force (f) while button X is held, and apply when released b=btn() n=b&32 -- buttons states f=f/2 + n/20 -- f approaches but does not exceed 3 k=b%4\2-b%2 -- -1,1 when LEFT or RIGHT is pressed if(f>n) then -- apply force and reset player_dx += f*k player_dy-=f f=0 end -- add velocity to the player's position player_x += player_dx player_y += player_dy -- invert the velocity before map collision test so that -- bouncing, friction and gravity can all be applied when -- there is /not/ a collision. works out slightly shorter player_dx = -player_dx player_dy = -player_dy if (mget(player_x / 4, player_y / 4) < 1) then -- no collision: invert the velocity to prevent bouncing, -- and apply friction and gravity in the same expression player_dx *= -.95 player_dy = .05-player_dy*.9 end
Because there is only a single loop used to generate the map and to do things every frame, it means that map data is generated every frame. This is what it looks like if the map distortion accidentally has a time component given to the sine wave:
[Please log in to post a comment]