Something went wrong. Try again.
a Bluesky client for PICO-8 picosky.vinnymac.dev
pico-8 simulation bluesky games
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273-- actions / navigation
function open_timeline(refresh) scr=scr_timeline posts=feed if refresh then feed={} posts=feed sel=1 scroll=0 loading=true if ONLINE then enqueue(op_req_timeline,active_feed-1) else seed_mock_feed() end else sel=prev_sel scroll=prev_scroll just_switched=true endend
function open_thread(refid,focus_refid) prev_sel=sel prev_scroll=scroll if loading then discard_loading=true end -- discard any in-flight REQ_MORE response thread_focus=focus_refid or 0 scr=scr_thread thread={} posts=thread sel=1 scroll=0 loading=true if ONLINE then enqueue(op_req_thread,refid) else mock_thread(refid) endend
-- open compose. rt>0 = reply to that refid; q>0 = quote that refid (qn = its-- author name, shown in the header). rt and q are mutually exclusive in practice.function open_compose(rt,q,qn) scr=scr_compose reply_to=rt or 0 quote_of=q or 0 quote_name=qn or "" draft="" poke(0x5f2d,1) -- enable keyboardend
-- clear the compose draft + reply/quote target (post sent, or compose cancelled)function reset_draft() draft="" reply_to=0 quote_of=0end
function send_draft() if #draft==0 then return end if ONLINE then if quote_of>0 then enqueue(op_post_quote,quote_of,draft) elseif reply_to>0 then enqueue(op_post_reply,reply_to,draft) else enqueue(op_post_create,0,draft) end set_status"posting..." scr=scr_timeline posts=feed else mock_send() endend
function load_more() if ONLINE and hasmore and not loading then loading=true enqueue(op_req_more) endend
-- demo only: mock feed in lua; no js bridge needed. shrinko8 strips on ONLINE true.if not ONLINE then-- find a post by refid across the timeline + open thread (demo thread lookup);-- demo refids are unique, so r=r or p keeps the first match like the old loop.function find_post(id) local r eachpost(id,function(p) r=r or p end) return rend
-- read a 16x16 avatar from the sprite sheet at gfx coords (sx,sy); demo avatars-- are baked there by tools/bake-avatars.py (online avatars arrive over gpio).function sget_avatar(sx,sy) local px={} for dy=0,15 do for dx=0,15 do add(px,sget(sx+dx,sy+dy)) end end return pxend
function mock_post(nm,hd,rt,tx,li,rp,re,id) return {name=nm,handle=hd,reltime=rt,text=tx, likes=li,reposts=rp,replies=re,refid=id,depth=0, img_state=0,expanded=false,scroll=0,vis_lines=3, liked=false,reposted=false,avatar_pixels=nil,linktext="",alt=""}end
-- 32x32 post images packed as 1024 hex chars (1 lua token each). generated by-- tools/gen-image.py; re-run only if the source images in tools/ change.local fox_hex="cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcddc6c6c6c6c6c6c6c6c6c6c6c6c6c6c6cc6cdcddcdcdcdcdcdcdcdcddcdcdcdcdcdc6cd4dc6cdc6cdc6c6cd4c6c6cdc6cdcdcdc44dc6cdc6cdcdc44dcdcdc6cdcc6c6c6d445c6cdcdc6c44dc6c6cdcdc6dcdcdcc4442dc6c6cd424cdcdcdc6cdcc6c6cd6d445ddd444444dc6c6c6cdc6cdcdcdccddcdd49489444ddcdcdcdc6cdc6cdc6c6cd9444444494446c6cdc6cdcdcdc6cdcd4454494984544dcdc6cdcdcc6cdcdc6d40652444406529dcdcdc6cddc6c6cdc492004494420044dc6c6cdc6cdcdcdc64444449849444444cdcdc6cdc6c6c6cd444444405444494ec6c6cdc6dcdcdcdc494947100df4444ddcdcdcdcc6cdc6c6d4847f7d6f7f494c6c6c6c6cdcdc6cdcc49477f7777444d6cdcdcdcdc6cdcdc6dc44f7767f7494dcdc6cdc6cdc6c6cdcce494f67f6f4449c6cdc6cdccdcdcdc6d44444f77f69448dcdcdcdcdc6cdc6cdc4944776767f4944444ec6c6dc6cdcdc6448f777f767444949444cdccdcdc6cdc449767677f7f448444984cd353535353494f77f777649494984495333b5b35b55444f776749444444977f535b535b53b34944f77f4449894476776335b5b5b5354444f6f44944444f77f77d35335335b59fff4774ffe4949767767d3b5b5b5b5b67776d6f777e444f77677d535b533535f767db3f767d449477f7633b5335b33b567db5b567db3454df7633"local npmx_hex="00000000000000000000000000000000000000000000000000000000000000000000010010010010010010010010000000010000000000000000000000000100000000010010010010010015d500000000100100000000000000005771001000000000001005001005001067d00000100001001000000000000005771001000000000000500010050010067d000001000010050000100000000057750010000000000000000005000100d7d000005000000100010010000000057750010000000000010000001001050d7600000001000010000500500000001f750001010000000010000000010010d76001000000100010000100010000007750000010000000000500010000500d7610010000500000010000000050001f7500000010000000000010050000005761001000000100001000055510000077d00000100500000000100555500105770010000000001000100005551000067d00001050001000000010051510005771001000001000000000000000001067d0000000000001000010100000000577100101010105000000000050010101510000000000000010000100000000000000100500500000000000001001000000100000000001001000101000000500100005000010000000000000105000000001000010000500000000100000001005000010001000000000000000000000000000000000000000"
-- unpack a 1024-char hex string into a 1024-element pico-8 pixel array (32x32).function unpack_img(s) local px={} for i=1,#s do add(px,tonum("0x"..sub(s,i,i))) end return pxend
-- mock feed: name|handle|reltime|text|li|rp|re|refid per line; avatars in gfx rows 16-127.function seed_mock_feed() feed={} -- depth-1 reply posts (refids 5001/2001/3001) are interspersed inline so the -- feed shows reply indentation naturally, like a real bluesky timeline. local data=[[Vincent|vinnymac.dev|1m|welcome to picosky! these posts live right inside the cart. press d for the credits. tap the tv icon to toggle crt. this text is intentionally long so you can test expand and collapse.|42|7|3|1Jay|jay.bsky.team|31m|custom feeds are the best thing we shipped. lexicon composability makes it possible.|203|37|14|5Vincent|vinnymac.dev|30m|this is why i built picosky :)|8|1|0|5001Bluesky|bsky.app|17m|the firehose flows even when you're not watching.|894|201|76|4zep.p8|lexaloffle.bsky.social|4m|pico-8 + bluesky. who would've thought.|128|19|11|2Emily Liu|emilyliu.me|3m|agreed - tiny client. big protocol.|4|0|0|2001Paul Frazee|pfrazee.com|9m|atproto keeps getting more interesting the deeper you go.|311|48|22|3Jay|jay.bsky.team|8m|this is why we open-sourced everything from day one.|6|1|0|3001Emily Liu|emilyliu.me|45m|just finished this little illustration. fall feels.|167|29|8|6Danabra|danabra.mov|1h|motion is the message|88|12|5|7Daniel Roe|danielroe.dev|1h 20m|once you go npmx it is hard to go back. dont get me started on the graphs.|112|18|9|10npmx|npmx.dev|2h|the ecosystem is finally growing up. everyone's shipping smaller and faster.|88|14|7|9bnewbold|bnewbold.net|2h 30m|lexicon design is hard. every field name you pick haunts you.|74|9|6|8]] for r in all(split(data,"\n")) do local f=split(r,"|",false) f[8]+=0 add(feed,mock_post(unpack(f))) end feed[3].depth=1 -- vincent→jay reply feed[6].depth=1 feed[6].avatar_pixels=sget_avatar(0,80) -- emily→zep reply feed[8].depth=1 feed[8].avatar_pixels=sget_avatar(0,64) -- jay→pfrazee reply feed[4].linktext="AT Protocol Overview" feed[9].img_state=2 feed[9].pixels=unpack_img(fox_hex) feed[9].alt="a small red fox curled up in a pile of autumn leaves" feed[11].img_state=2 feed[11].pixels=unpack_img(npmx_hex) feed[11].alt="the npmx logo" feed[2].avatar_pixels=sget_avatar(0,64) -- jay feed[5].avatar_pixels=sget_avatar(0,16) -- zep feed[7].avatar_pixels=sget_avatar(0,32) -- pfrazee feed[9].avatar_pixels=sget_avatar(0,80) -- emily feed[10].avatar_pixels=sget_avatar(0,48) -- danabra feed[11].avatar_pixels=sget_avatar(0,96) -- danielroe feed[12].avatar_pixels=sget_avatar(0,112) -- npmx posts=feed loading=false hasmore=falseend
-- per-post thread replies: root_refid|name|handle|reltime|text|li|rp|re|reply_refid|avslot-- avslot: -1=none, else row=slot*16+16 (0=zep,1=pfrazee,2=danabra,3=jay,4=emily,5=danielroe,6=npmx)local tdat=[[1|Jay|jay.bsky.team|3m|love this. a social client on a fantasy console.|9|1|0|101|31|Emily Liu|emilyliu.me|2m|the crt mode. instant follow.|5|0|0|102|42|Paul Frazee|pfrazee.com|5m|and we almost didn't ship it. glad we did.|11|1|0|201|12|Danabra|danabra.mov|4m|the crossover i didn't know i needed.|7|0|0|202|23|bnewbold|bnewbold.net|4m|jetstream is right there if you want it.|8|1|0|301|-13|Paul Frazee|pfrazee.com|3m|subscribe at events.feeds.bsky.app|5|0|0|302|14|Vincent|vinnymac.dev|2m|you're looking at it :)|11|2|0|401|-14|Danabra|danabra.mov|1m|the crossover i needed.|7|0|0|402|25|bnewbold|bnewbold.net|3m|wait until you hit lexicon design.|8|1|0|501|-15|Jay|jay.bsky.team|2m|this is why we open-sourced everything.|6|0|0|502|36|Jay|jay.bsky.team|3m|every shipped thing counts.|9|1|0|601|36|Danabra|danabra.mov|2m|the palette. perfection.|8|0|0|602|27|Paul Frazee|pfrazee.com|2m|mcluhan was right.|6|1|0|701|17|Emily Liu|emilyliu.me|1m|motion as narrative. i feel it.|5|0|0|702|48|Paul Frazee|pfrazee.com|3m|still have regrets about 'cid'.|8|1|0|801|18|Jay|jay.bsky.team|2m|every field name is a long-term commitment.|6|0|0|802|39|bnewbold|bnewbold.net|4m|the ecosystem never stops shipping.|5|0|0|901|-19|Emily Liu|emilyliu.me|3m|as a js dev i feel this deeply.|7|1|0|902|410|Paul Frazee|pfrazee.com|4m|i have not looked at npm.im since.|7|1|0|1001|110|Jay|jay.bsky.team|3m|the dependency graph view is genuinely good.|5|0|0|1002|3]]
-- demo thread: open post + unique depth-1 replies looked up from tdatfunction mock_thread(refid) local src=find_post(refid) thread={src or mock_post("?","?","","this post left the demo feed.","0","0","0",refid)} for row in all(split(tdat,"\n")) do local f=split(row,"|",false) if f[1]+0==refid then local r=mock_post(f[2],f[3],f[4],f[5],f[6],f[7],f[8],f[9]+0) r.depth=1 local av=f[10]+0 if av>=0 then r.avatar_pixels=sget_avatar(0,av*16+16) end add(thread,r) end end posts=thread loading=falseend
-- demo: echo a composed draft into the feed so posting feels realfunction mock_send() add(feed,mock_post("you","you.demo","now",draft,"0","0","0",9000+#feed),1) reset_draft() set_status"posted!" sfx(6) open_timeline(false)endend
-- m3x6 (daniel linssen): 3px-wide true-lowercase at 0x5600; body+draft opt in via 0x5f58.function load_font() poke(0x5600,4,4,7) local f=split("0x0000.0000,0x0000.0000,0x0202.0202,0x0000.0200,0x0000.0505,0x0000.0000,0x0505.0705,0x0000.0507,0x0407.0106,0x0000.0203,0x0204.0100,0x0000.0401,0x0102.0502,0x0000.0305,0x0000.0102,0x0000.0000,0x0101.0102,0x0000.0201,0x0202.0201,0x0000.0102,0x0205.0000,0x0000.0005,0x0702.0000,0x0000.0002,0x0000.0000,0x0000.0102,0x0700.0000,0x0000.0000,0x0000.0000,0x0000.0200,0x0202.0404,0x0000.0101,0x0505.0506,0x0000.0305,0x0202.0302,0x0000.0702,0x0204.0403,0x0000.0701,0x0403.0403,0x0000.0304,0x0406.0505,0x0000.0404,0x0403.0107,0x0000.0304,0x0503.0106,0x0000.0605,0x0204.0407,0x0000.0202,0x0502.0506,0x0000.0305,0x0605.0503,0x0000.0304,0x0002.0000,0x0000.0002,0x0002.0000,0x0000.0102,0x0102.0400,0x0000.0402,0x0007.0000,0x0000.0007,0x0402.0100,0x0000.0102,0x0204.0403,0x0000.0200,0x0505.0506,0x0000.0601,0x0604.0300,0x0000.0705,0x0505.0301,0x0000.0705,0x0101.0600,0x0000.0701,0x0505.0604,0x0000.0705,0x0705.0600,0x0000.0601,0x0702.0204,0x0000.0202,0x0705.0600,0x0000.0304,0x0505.0301,0x0000.0505,0x0202.0002,0x0000.0202,0x0202.0002,0x0000.0102,0x0305.0101,0x0000.0505,0x0202.0202,0x0000.0402,0x0707.0300,0x0000.0507,0x0505.0300,0x0000.0505,0x0505.0600,0x0000.0305,0x0305.0700,0x0000.0101,0x0705.0600,0x0000.0404,0x0101.0600,0x0000.0101,0x0701.0600,0x0000.0304,0x0207.0202,0x0000.0202,0x0505.0500,0x0000.0705,0x0505.0500,0x0000.0205,0x0705.0500,0x0000.0507,0x0205.0500,0x0000.0505,0x0605.0500,0x0000.0304,0x0204.0700,0x0000.0701,0x0101.0103,0x0000.0301,0x0202.0101,0x0000.0404,0x0202.0203,0x0000.0302,0x0000.0502,0x0000.0000,0x0000.0000,0x0000.0403,0x0000.0201,0x0000.0000,0x0507.0506,0x0000.0505,0x0507.0503,0x0000.0705,0x0101.0106,0x0000.0701,0x0505.0503,0x0000.0305,0x0103.0107,0x0000.0701,0x0301.0106,0x0000.0101,0x0501.0106,0x0000.0705,0x0507.0505,0x0000.0505,0x0202.0207,0x0000.0702,0x0404.0407,0x0000.0304,0x0503.0505,0x0000.0505,0x0101.0101,0x0000.0701,0x0507.0705,0x0000.0505,0x0505.0503,0x0000.0505,0x0505.0506,0x0000.0305,0x0103.0507,0x0000.0101,0x0505.0506,0x0000.0403,0x0503.0507,0x0000.0505,0x0407.0106,0x0000.0304,0x0202.0207,0x0000.0202,0x0505.0505,0x0000.0705,0x0505.0505,0x0000.0205,0x0705.0505,0x0000.0507,0x0202.0505,0x0000.0505,0x0205.0505,0x0000.0202,0x0202.0407,0x0000.0701,0x0302.0204,0x0000.0402,0x0202.0202,0x0000.0202,0x0602.0201,0x0000.0102,0x0704.0000,0x0000.0001,0x0205.0200,0x0000.0000",",",false) for i=0,#f-1 do poke4(0x5700+i*4,tonum(f[i+1])) end -- m3x6's true lowercase sits at the uppercase code points (pico-8 convention); -- swap the a-z and a-z glyph banks so standard ascii renders real lowercase. for i=520,727 do -- 65*8 .. 90*8+7; +256 = matching a-z slot local a=0x5600+i local t=peek(a) poke(a,peek(a+256)) poke(a+256,t) endend
-- uppercase ascii (no string.upper in pico-8). the reltime reads best as the-- native font's short uppercase glyphs, so we force it up before drawing.function upper(s) local o="" for i=1,#s do local n=ord(s,i) o=o..(n>=97 and n<=122 and chr(n-32) or sub(s,i,i)) end return oend
function _init() load_font() poke(0x5f2d,1) -- mouse+keyboard theme=(rpin(126)>>1)&1 -- adopt the page theme set before boot (no light flash) pal(13,140,1) -- shadow 13 -> bluesky deep blue #065ab5 -- static pause-menu items registered once; music/theme/feed update live in _update. menuitem(4,"refresh feed",function() open_timeline(true) end) menuitem(5,"credits",function() enter_demo() end) wpin(p_turn,their_turn) -- let js go first open_timeline(true) scr=scr_splashend
function update_splash() stik+=1 if stik>132 or btnp(4) or btnp(5) then scr=scr_boot endend
function _update() comms_update() if scr==scr_splash then update_splash() elseif scr==scr_compose then update_compose() elseif scr==scr_demo then update_demo() else update_nav() end if scr==scr_boot and #feed>0 then scr=scr_timeline sfx(9) -- boot fanfare end -- adopt page theme (pin 126 bit1) on change; local toggle round-trips harmlessly. local pt=(rpin(126)>>1)&1 if pt~=last_pt then theme=pt last_pt=pt end -- refresh pause-menu labels from one spot; _update frozen while menu is open. if demo_track~=mi_track then mi_track=demo_track menuitem(1,music_label(),music_menu) end if theme~=mi_theme then mi_theme=theme menuitem(2,theme_label(),theme_menu) end -- feed switcher: appears once js sends >1 feed; reloads timeline after menu closes. if #feeds>1 and active_feed~=mi_feed then mi_feed=active_feed menuitem(3,feed_label(),feed_menu) end if active_feed~=loaded_feed and scr==scr_timeline then loaded_feed=active_feed open_timeline(true) end -- a screen change kicks off the wavy reveal wipe (drawn in _draw). if scr~=lastscr then scr_wt=time() trans_dir=scr>lastscr and 1 or -1 prev_was_thread=lastscr==scr_thread prev_was_compose=lastscr==scr_compose lastscr=scr end -- tell js via pin 127: bit0=compose_intent (ios keyboard), bit1=theme (page mirrors). wpin(127,compose_intent()|(theme<<1))end