Developer notes-to-self to accompany PICOhaven source code, to make it easier to remember the big picture if I take a break and come back to it. And perhaps they'll be useful to others. I don't claim to be a software engineering expert, this is not my day job :), but I iterated a number of times to get here.
These have not been fully cleaned up so may include some obsolete notes / global variables no longer used...
This file is focused on development notes not play notes. See README.md for a game overview and where and how to play it.
The game source is primarily split between two files:
picohaven###.lua
-- main game code, about 1700 lines of PICO-8 flavored Lua, plus commentspicohaven###.p8
-- the game 'cart' which has sprites, map, sfx (plus some game strings stored in unused gfx/sfx space via helper cart storedatatocart...), and which #includes that .lua file of code
My typical development + test loop (v1):
- Edit the source code in
picohaven###.lua
using VScode or another external editor-- it is too large for the PICO-8 built-in editor to open because of its in-code comments, which I didn't want to remove - Whenever I want to run it, because the base code is far over the compressed size limit, strip comments and whitespace with a command like this (suggested on the PICO-8 forums):
./minify_reg.sh picohaven_v11a.lua minify_rules2.sed > picohaven_v11a_sminify.lua
- I try to not do other minification to keep the file readable-- comment and whitespace stripping is currently just barely enough
- In the PICO-8 application, load and run
picohaven###.p8
(which as of this writing just includes the source frompicohaven_v11a_sminify.lua
, plus contains the sprites, sfx, map, and so on). If it's already open you can just hit Ctrl-R to reload changes (after running the minify_reg command above), which gives a nice quick testing loop- Edit this .p8 file when needed to edit graphics, maps, sound, music.
- Core game data (player cards, upgrades, items, monster decks and stats) is stored in strings in the .lua source, but I have a helped spreadsheet that lets me adjust all the numbers and abilities, then concatenate them into a string I copy and paste into the .lua source
- Story text (shown before/after each level) wouldn't fit within the cart limits, originally... so it's now stored in a binary format in unused space in the cart's sprite and sound data, using separate program
storedatatocart4r.p8
. I edit the story text in a spreadsheet which concatenates it into a specific format for storedatatocart.p8, then run that to overwrite those parts of the picohaven###.p8 cart - Note: INFO at the PICO-8 commandline after a change will show token/character usage including the #included Lua
Revised v1.1 workflow:
- As above, but using minifier Shrinko-8 instead of my shell/.sed script, e.g.
shrinko8.py picohaven_v11a.lua picohaven_v11a_sminify.lua --minify --preserve "*.*"
(that minified code is no longer readable, unfortunately) - The most current versions of the source are picohaven_v11a.lua and picohaven_v11.p8
The source code itself has a table of contents for code organization ,and moderately detailed in-code comments.
In addition, some notes I refer back to during development:
The game uses a reduced palette of 8 colors (with exceptions for the player and final boss):
- 0 (black) -- general use
- 1 (dark blue) -- environment (water)
- 5 (dark grey) -- general use
- 6 (light grey) -- text, obstacles, general use
- 7 (white) -- emphasized text
- 8 (red) -- health, wound, attacks, warnings, elite enemy eyes, sprite highlights
- 12 (cyan) -- selection cursor, selected items, button-press prompts, sparing use as sprite highlights
- 13 (purplish grey) -- treasure, general use
- 0: actor initialization during initlevel (deprecated)
- 1: impassible (to move and LOS)
- 2: impassible move (unless jump), allows LOS
- 3: animated scenery (default: 4 frames)
- 4: triggers immediate action (trap, door)
- 5: triggers EOT action (treasure)
- 6: edge of room (unfog up to this border)
- 7: is doorway (trigger door open at EOT)
Generally, the game uses a state machine to partition different gameplay (i.e. different update and draw behavior) into different functions, rather than one update function with many if statements or global variables that set behaviors. changestate() changes to a new state, calling its init function, which typically sets the new update and draw functions if needed.
However, there's some overhead in coding a new state, especially if its behavior is very similar to other states or only used in one place, so some changing gameplay within states is controlled by global variables (see below for the many global variables that indicate for example the phase of a boss fight, whether the message box is in 'interactive scrolling mode', and so on).
Not included in above: 'scroll message box' temporary state.
Global variables and data structures are used liberally to avoid burdening every function call with extensive lists of parameters, given the strict code size limitations. To mitigate the risks of this, all other variables should be explicitly declared as local, and I keep a summary of global variables here to refer back to.
debugging (currently removed to save tokens):
debugmode
(bool)logmsgq
(bool)
flow control and state related:
state, prevstate, nextstate
-- prevstate/nextstate used by a few functions that run and then proceed to next state or return to previous state_updstate, _drwstate
-- current update/draw routines, different depending on state_updprev
-- previous _updstate function: used to store an _updstate to return to after execution a specialized _upd function it's not worth changing state for (for example for msgq review)initfn[]
-- array of init functions to run when changing to [state]msgq
-- queue of strings to display in msgbox (max length ~22 chars/line depending on char)
gameplay / level related:
dlvl
-- dungeon leveldoorsleft
-- doors left to open in level, part of check for end of levelfog[]
-- 11x11 array with either '1' (fog of war) or 'false' (unfogged)gppercoin
-- scales with difficultytrapdmg
mapmsg
-- message to show in map area (at beginning and end of level)pretxt[], fintxt[]
-- pre- and post-level text to display (stored in unused sprite/sfx memory by a separate cart compresstxt.p8 and retrieved during runtime using decode4r(), to save ~7k characters)tutorialmode
-- determines whether to display additional messages and tipsdifficulty
difficparams[]
-- sets HP and gold scaling per level, etc (and description)
Special wincons, event triggers:
lootedchests
-- # of chests looted in level (sometimes an alt wincon or trigger)bossphase2
-- trigger special phase 2 of boss fightwongame
-- trigger "reture" option in town
animation-related: (the .ox/.sox approach to animation is based on the system seen in the Lazy Dev Academy Roguelike youtube videos)
animt
-- animation timer: set to 0 to initiate animation-only updates that increment animt, until animt=1.sox, .soy
-- the starting location (in pixels) of an object we're animating the motion of.ox, .oy
-- the current during-animation display offset (in pixels) of an object we're animating the motion of. when animt=1, .ox,.oy should have decreased to zero, as the object should be displayed at its final location.fram
-- increments every cycle through programafram
-- animation frame, cycles from 0->3 repeatedlyanimtd
-- increments of animt from 0-1 during animation (lower increment = slower animations)shake
-- how many pixels to shake the map area by each frame, used for specialized animations (0 = no shaking)screenwipe
-- ranges from 0 (no wipe) to 63 (initiative wipe)msg_td
-- scroll msg every # framesact_td
-- update actor anims every # frames
UI selection related:
selx, sely, seln
-- x, y, and n positions of selection cursor within a list (seln = position in a 2D list)selvalid
-- true = current selection is valid (mostly used to check valid move and attack targets for player)showmapsel
-- true = show yellow selection cursor on map (typically paired with the update() function calling selxy_udpate_clamped() to update selx, sely)
cards and deck-related
pdeck
-- player deck of cards (each element a crd data stricture)tpdeck
-- pdeck with virtual "rest" card appended, for choosecards()longrestcrd
-- pointer to long rest card in master deckcrdplayed
-- globally saved link to card currently being played by player, so that we can provide an option to undo an action and restore card/deck state before it's completedcrdplayedpos
-- where in card list this was, to allow smooth insertion back into list in case of undo
Card data structures. Enemy cards may only have [0] and [1] fields:
card[0]
= initiativecard[1]
= card 'code' that describes action (e.g.█2➡️3∧
= "attack 2 at range 3 and wound")descact[]
is a lookup table that maps █ to attack, for example
card[2]
= status (0 = in hand, 1 = discarded, 2 = burned)card[3]
= title (e.g. "hurl sword")
If I did it again, I'd likely make those each properties (e.g. card.status
instead of card[2]
), to make the code more readable when coming back to it after a break, and incidentally saving a few tokens (in PICO-8's token-counting system, a.property uses 2 tokens while a[property] uses 3-- a small difference but it adds up).
Crd "parsed individual card actions" structure. For example, from the sample █2➡️3∧
card above:
crd.act, crd.val
-- action and value, e.g.█,2
for "attack 2"crd.mod, crd.modval
-- 2nd param and value, e.g.➡️,3
for "range 3"crd.rng
-- attack range (if any), 1 for meleecrd.stun, crd.wound
-- if card inflicts that conditioncrd.burn
-- if burned after use (player only)crd.aoe
-- to indicate one of various Area-of-Effect patterns is applied to the attack (only one simple pattern is implemented)crd.special
-- for special player or enemy actions that don't fit the above and have custom code for handling them, e.g. "call" (summon), "rest", "howl" etc
actor[]
data structures:
the below are all properties of form actor[n].foo
e.g. actor[n].crds
, often used with alias a=actor[n]
to be a.hp
, etc.
Note that actor[1]
is initialized to = p
(the player data structure, with similar fields)
spr
-- first sprite frame (and the following 3 for animation)bigspr
-- player only: index of 16x16 profile spritelvl
-- player levelx, y
-- locations (in map tiles, 0-10)hp, maxhp, xp, gold
-- (no xp, gold for enemies)shld
-- shield value (0 if no shield)pshld
-- persistent shield (restored each round)stun, wound
-- true/false (or often, true/nil) statusescrds
-- list of cards (card data structure above) to play or choose from this turn. for enemies, 1-2 entries. for player, can be 1, 2, or 4.crd
-- card currently being acted on (in the "crd parsed individual card actions structure" noted above)init
-- current turn initiativecrdi
-- index of card within a.crds[] to play next. only used for enemies, to keep track of position in a list of cards to play.type
-- for enemies, links back to a row in theenemytypes[]
data structure which includes the deck,e tcactionsleft
-- for player, list of actions they can still take this turn (starts at 2, decrements)noanim
-- property of actors[] that don't have frames to animate throughephem
-- property of actors[] which are ephemeral visual indicators that should only exist for an animation cycle and then be deleted (attack / damage animations)
enemytype[] data structure:
enemytype.crds, .crd, .init
-- redundant with above? tbd.hpdrawn
-- a temporary value used during drawheadsup() to track whether an instance of this enemytype has been drawn with its details (due to being selected on map), to avoid drawing other instances of the same enemytype over it
Town and upgrades related (misc):
townmsg
-- message to show in towntownlst[]
-- list of in-town menu options (dynamically generated)upgradelists
-- global used by two different upgrade routines (deck and mod) to pass list of lists to draw function (to share same draw function)pmodupgradesmaster
-- mod upgrades available, combining the starting set (the firstpmodupgradessize
) and 1 more per leveluppdeckmaster[]
-- all potential player deck cards (of thecard[]
data structure above), combining the starting pdeck (the firstpdecksize
entries) and future upgrade options (2 upgrade cards per level)
Turn sequencing (tracking active actor, action, etc):
ilist[], initi
-- list of{initiative, actor#}
pairs for all actors this turn, sorted in initiative order.initi
is the index of the row within this list currently actingactorn
-- index for actor[] of the active actor (during turn execution, read from sorted-by-initiative list of actors in ilist)- See also actor.crds, .crdi, .init, and similar above in the actor data structure notes
Other (to organize):
mvq
-- list (queue) of adjacent squares the current actor is moving through, starting at current position, in format{{x=x0,y=y0},{x=x1,y=y1},...}
. Used to move and animate an actor through a path, checking for triggers (traps, doors, etc) at each stepdirx[], diry[]
-- save tokens for common x/y +-1 offsetsrestburnmsg
-- message describing which card was burned for a "long rest" (chosen during choosecards() but not displayed until player action)godmode
-- sets player hp/attacks/move/range to large #s behind the scenes (inflated atk/mv/rng not shown in cards). disabled to save tokensavatars, avatar
-- selection of name, small sprite, and large sprite (for profile) for player to provide a few options: DEPRECATED to save tokens
Major game content DBs loaded from strings (some also listed in sections above):
lvls[]
-- all non-map data for each level. generated by writing out level info an external spreadsheet which compiles them into one long string to be split.pretxt[], fintxt[]
-- pre- and post-level text to display (stored in unused sprite/sfx memory by a separate cart compresstxt.p8 and retrieved during runtime using decode4r(), to save ~7k characters)enemytype[]
--enemydecks[]
-- list of cards per enemy, indexed usingenemytype.name
oractor.name
as keypdeckmaster[]
-- all potential player deck cards (of thecard[]
data structure above), combining the starting pdeck (the firstpdecksize
entries) and future upgrade options (2 upgrade cards per level)pmoddeck
-- initial modifier deckpmodupgradesmaster
-- mod upgrades available, combining the starting set (the firstpmodupgradessize
) and 1 more per leveluprndtreasures
-- selections for each chest, ranging from [g]old to [d]amage to [x]pstoremaster[], store[], pitems[]
-- similarly: master list of all items in game, items currently in store, and items owned by playerslvrstl
-- pointer to special item in storemaster[] (not available in store)-- silversteel blade found in one level
Persistent data (save/load):
dgetseti
-- auto-incrementing index allowing dget()/dset() to not specify index on each call
UI layout related (many of them constants, to avoid hard-coding a lot of numbers and make tweaks simpler):
msg_x0
-- x offset added to message box (0 for normal location)msg_w
-- width of message boxmsg_yd
-- pixel-level scrolling offset of msgboxmap_w
-- width of map including border (also typically of message box)hud_x0
-- x0 of HUD columnhud_py
-- actor box on HUD: y poshud_ey
-- enemy HUD y pos and spacing (at 0, hud_ey, hud_ey*2, etc)ehudn[]
-- formerly a global, turned into local in drawheadsup() and recalculated every cyclegc_fg, gc_bg, gc_bg2, gc_sel
-- (DEPRECATED, hard-coded once selected, to save tokens) four global colors used (especially gc_bg = background color for e.g. profile and card selection screens, and gc_sel = selection box color)minispr
-- maps special characters to sprites to display instead (in printmspr()), e.g. Shift-A special character = "attack" option, also seesh()
and the Sprite "font" notes below:
Various characters outside the standard alphanumeric [a-z0-9] encode specific sprites to be mixed in with text, see minispr[]
and printmspr()
.
NOTE: As of v1.1 (Aug 2022), this approach has been replaced by a PICO-8 custom font poked into memory and some of this section is obsolete when it comes to the details. The printmspr() function has been updated (see it for details), but I will update this documentation at some point...
The most common of these are Shift+letter (i.e. replacement for the extended double-width characters, CHR(128) to CHR(153)):
- [i]tem, [p]ush, [a]ttack, [m]ove, [h]eart, [g]old, [j]ump,
- [r]ange, [s]hield, [w]ound, [b]urn, [z]stun, [l]oot
With sprites shown below:
For example, "웃" is what PICO-8 displays if you press shift-j, this is CHR(127) and is used to symbolize "jump" or to print the "jump sprite", whose number is stored in minispr["웃"]
In addition, some letters written in 'puny font' (appear as CAPS in text editor, CHR(65) to CHR(90)) have special meanings:
- [A]..[H] for AoE sprites #21-28 (only H used to date, for AoE pattern #8, '8 adjacent cells include diagonals'...)
- [I]..[M] for item sprites (created but not currently used: [N]..[O] for 'used item' sprites)
- [U],[D], [X] for simplified arrows (7x5) and ❎ (7x7)
- p.crds (array of 4 playable full cards, init and all)
- p.crd: parsed data struct (.act,.val,.stun, etc) for card currently being played
- p.init
- a.crds or a.type.crds: list of enemy cards (redundant)
- a.crd: data struct for card currently being played
- a.init or a.type.init (redundant)
During development I regularly ran into resource limits (tokens, characters, compressed size) and had to restructure code or data. Keeping some notes here about resource usage (more in notebook)
Total resource usage vs. PICO-8 platform limits:
8163 / 8192 tokens
41356 / 65535 characters
(after stripping comments and whitespace)15159 / 15616 (97%) compressed bytes
(also after stripping comments and whitespace)
Token and compressed size usage (only including the major contributors to this one), sorted by code organization section:
tokens used (/8192) |
compressed chars (/15616) |
code section |
---|---|---|
1159 | 2066 | 6) main UI draw loops and card draw functions |
1073 | 2041 | 4b) player action loop (some of this is also in enemy action loop) |
865 | 1496 | 9) miscellaneous helper functions |
790 | 1674 | 4a) enemy action loop (includes some common functions used in 4b) |
628 | 3538 | 11) inits and databases (substantial strings of data) |
580 | 159 | 3) pre-combat states (new turn, choose cards, etc) |
517 | 1368 | 5) post-combat states (cleanup, etc) |
377 | 7) custom sprite-based font and print functions | |
359 | 14) levelup, upgrades | |
355 | 1283 | 15) town and retirement |
275 | 18) load/save | |
271 | 17) pathfinding (A*) | |
211 | 646 | 1) core game init/update/draw |
200 | 8) menu-draw and related functions | |
129 | 10) data string -> datastructure parsing + loading | |
117 | 12) profile / character sheet | |
102 | 4) action/combat loop | |
99 | 13) splash screen / intro | |
56 | 2) main game state machine | |
0 | x) pause menu items (deprecated) | |
0 | 16) debugging + testing functions |
Under the hood rewrite to use the PICO8 custom font functionality (saved ~150 tokens at the cost of many more characters and larger compressed size), some further code cleanup, minor new features (added tokens), and use of the Shrinko-8 minifier (saved significant characters and compressed size)
Total resource usage vs. PICO-8 platform limits:
8031 / 8192 tokens
(even after adding in more tutorial notes and enhancements)31840 / 65535 characters
(after aggressive minification)13600 / 15616 (87%) compressed bytes
Use PICO-8 custom font (available from 0.2.2 on) to include the attack/move/etc icons and save tokens relative to my minispr/printmspr functions... but at cost of more characters and compressed size- Fog-related code
- If I develop future chapters, pare down messages / remove tutorial hints (and suggest playing chapter 1 first to learn)
- Encode/hard-code more level data in the DB rather than extracting it from the level at runtime (e.g. player starting location, door locations and # of doors, hard-code coordinates of an area to unfog for each door)? But makes adjusting level maps more annoying. Saves tokens at small cost in characters.
- More aggressive minification? (add code annotations to table keys to preserve, then use shrinko-8 more aggressively-- but this only saves chars/compressed size, not tokens which are more often the limit)
- Rework the split-string-to-kv-pairs function to only store the key names once if they're the same for every row (only saves chars/compressed size, not tokens)
version | date | estimated hours (not tracked) |
notes |
---|---|---|---|
v0 | July 2021 | 4 | brainstorming, mockups on graph paper and later in image editor to see what kind of UI's even possible within 128 pixels (no code) |
v0.1 | Aug 2021 | 15? | first playable prototype of concept (limited animations or text, no regard for duplicated code or efficiency), get first feedback |
v0.2 | Sep | 80? | main work: add story, enemies, animations, music, special actions, items, save/load, iterate on game, refactor code to reduce size, it seems "mostly done except for some polish" (ha!) |
v0.9 | Oct 12 | 60? | self-playtesting and iteration on details, more debugging, story, upgrades/balance, enemies. in-cart data storage and refactors to reduce size below limits. perhaps feature-and-content complete? release to a few folks for feedback |
v1.0 | Oct 20 | 10 | updates based on feedback, improve enemy pathfinding, other improvements and debugging, first public release on BBS! |
v1.0b | Oct 24 | 3 | more feedback-based revisions: more info messages, improve hand selection UI |
v1.0d | Oct 28 | 6 | even more tutorial-related messages, "undo" option for move/attacks before confirmed, screen shake on "*2" mod |
v1.0e | Nov 3 | 10 | almost no functional changes: add more detailed code comments, documentation, organization, read through all code and clean up. more time than expected! |
v1.1 | Aug 31, 2022 | 15 | under the hood changes to free up some tokens (P8 custom font), plus more tutorial-related messages and minor enhancements and bugfixes. Partly setting groundwork to work on a "Chapter 2" some day |
Time spent on this project
I'd guess somewhere between 150-250 hours (including learning, reading, poking around with music and graphics, and actually playing it over and over), but it's hard to know. This was mostly developed in the background over the course of three months, plus some additional time on documentation and writeup. Some weeks this was the equivalent of 2-3 evenings/week, but there were weeks I didn't touch it at all.