Log In  

Cart #squiddy-0 | 2021-09-28 | Code ▽ | Embed ▽ | No License
28

(click to expand)

Heeey, it's Squiddy! This is a game made in 1017 bytes for PICO-1k Jam, co-designed and pixelled by @castpixel (twitter). This is our second production as pod; you can find the first one here. Squiddy is also up on itch.

Technical Details

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 _

Sprite Storage

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

Map Data

The map is generated from sprite pixels: each pixel in the spritesheet corresponds to a 4x4 block of map tiles. This allows for a 2x2 screen room to be described by an 8x8 pixel sprite. This produces a very blocky world however, so the sampling position in the spritesheet is also 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. Here is an example of a room without and with distortion:

Apart from obscuring the coarse resolution of the source data, using this distortion had some other nice side-effects. It produces local organic details like a single protruding tile at the edge of some 4x4 clusters, and larger features like the raised 'platform' that the statue is sitting on. As the seaweed placement is based on both x and y position, having varying y positions for the ground blocks also means that the seaweed placement test could leverage that to look scattered without using an additional pseudo-random number expression.

It is also possible to reuse the structure of regular sprites (that are used as visible graphics) as map shapes. This only ended up happening once: the left side of the last tunnel is shared by 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 freely explore the spritesheet.

Superloop

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

Janky Physics

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

Squiddy Racer

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:

P#98017 2021-09-30 01:22 ( Edited 2021-09-30 02:33)

1

This is really cool!

How you got all this into such few lines of code is incredible! - I don't understand it really, but am suitably impressed.

And, I found Horsey :-)

Thanks for sharing!

P#98032 2021-09-30 10:20

OH MY GOD THIS IS JUST AWESOME @zep

P#98033 2021-09-30 11:18

It’s amazing how much you fit into just 1KB! On top of that, it’s fun, the con controls remind me of Balloon Fight.

P#98038 2021-09-30 14:42

wow
damn
niceee!

P#98041 2021-09-30 15:40

Better than games I made with millions of characters.
No but it's extremely cool to see this compacted down but still so lush with details. Thanks castpixel and zep for this beautiful cart!

P#98043 2021-09-30 15:49

This is... Wow. This is definitely one of the most technically impressive things I've seen done on the PICO-8!

P#98044 2021-09-30 16:06

Superb design, graphics and tech! I found horsey, and predict studying the code will be hours more fun!

P#98060 2021-09-30 20:15

This is really cool! Very fun for being so small, I also really liked the instructions art for the game, well done!

P#98066 2021-09-30 21:55

guess i won, next time i'll do it the intended way

P#98705 2021-10-15 19:20

I have been playing for the last about 4 hours waiting for me to fall down because I broke out of the world which is a thing you can do and have now just fallen down

P#98773 2021-10-17 05:38

[Please log in to post a comment]

Follow Lexaloffle:        
Generated 2021-10-20 19:35:25 | 0.022s | Q:37