Scripting in Octo


I recently participated in the second Nokia 3310 Jam using Octo. Over the course of a hectic four-day development cycle I managed to cobble together a commodity trading strategy game that was essentially a menu-based text adventure with the look and feel of the Nokia 3310 OS: Business is Contagious. Over the course of development I borrowed and iterated upon many ideas from my previous ambitious Octo title, An Evening to Die For. In this writeup, I'd like to delve into the details of a few programming techniques invented for these games.

The XO-Chip extended instruction set augments the CHIP-8 12-bit addressing space (of which 3.5kb is usable for your game) with four more bits via the i := long XXXX instruction. At the cost of a four-byte instruction, you can point the index register anywhere in a spacious 64kb of RAM. While XO-Chip eschews the painful bank-switching approach employed by many 8- and 16-bit game consoles, the 64kb address is not all created equally. It's still only possible to call subroutines or jump to addresses in the low 3.5kb. Thus, I often describe this low region as "code ram" and the upper 60kb as "data ram". Technically one could place the entrypoint to a subroutine in the bottom edge of the low 4k and have execution proceed into "data" space, but this limits the kind of code which can go in such a subroutine.

In An Evening to Die For, I introduced a pair of macros which make it possible to interleave building up these two regions of a ROM:

:calc CODE_POS { 0x200  }
:calc DATA_POS { 0x1000 }
:macro to-code { :calc DATA_POS { HERE }  :org { CODE_POS } }
:macro to-data { :calc CODE_POS { HERE }  :org { DATA_POS } }

Octo can resolve forward-referenced labels, but declaring before use is necessary if you want to perform compile-time calculations with an address- a necessity for more sophisticated metaprogramming. Consider these routines for laying down pointers and constructing self-modifiable i := long XXXX instructions:

:macro indirect LABEL {
    # compiles into i := long 0x0000
    # with a label in the middle!
    0xF0 0x00 : LABEL 0x00 0x00
}
:macro unpack16 ADDR {
    # stash a 16-bit address in a pair of
    # registers (in code ram),
    # ready to write into an "indirect" slot
    :calc hi { 0xFF & ADDR >> 8 }  v0 := hi
    :calc lo { 0xFF & ADDR      }  v1 := lo
}
:macro pointer ADDR {
    # stash a 16-bit address as bytes in data ram
    :byte { 0xFF & ADDR >> 8 }
    :byte { 0xFF & ADDR      }
}

With these tools in hand, I can not only ensure that none of my graphics or lookup tables eat into precious code ram, but easily perform pointer indirection. I can build data structures, and pass addresses into subroutines!

I used the same features extensively in Business is Contagious, but that was only the beginning. This new game had far more game logic and mechanics to squeeze into that dwindling 3.5kb. The solution: write an interpreter, and stuff as much of that logic as possible in data ram!

Interpreted code would necessarily be at least an order of magnitude slower, but for many applications this was irrelevant. For example, the logic that sequences together a series of screens of text and graphics, with pauses for player interaction. Logic like this has a very sterotypical, repetitive structure that can be compressed as a series of simple bytecode instructions. The selection of instructions in my interpreter were chosen to suit the things that happened frequently in my game, giving the whole process the feel of Huffman encoding.

Below is a cleaned up and streamlined iteration on the interpreter I developed for Business is Contagious, minus the game-specific features:

# v0-v2 are used as temporary working registers
:alias SCRIPT_A  v4
:alias SCRIPT_PC v5
: script-exec # entrypoint; take a pointer in v0-v1
    i := script-base
    save v0 - v1
    SCRIPT_PC := 0
: script-step
    indirect script-base
    i += SCRIPT_PC
    SCRIPT_PC += 1
    load v0 - v2
    jump0 script-dispatch
: script-dispatch
: do-op-end
    return
: do-op-clear
    clear
    jump script-step
: do-op-pause
    # a debounced wait-for-key routine:
    vf := OCTO_KEY_E
    loop if vf  key then again # wait for release,
    loop if vf -key then again # wait for press,
    loop if vf  key then again # wait for release...
    jump script-step
: do-op-const
    SCRIPT_A  := v1
    SCRIPT_PC += 1
    jump script-step
: do-op-random
    i := random-target
    save v1 - v1
    SCRIPT_PC += 1
    :next random-target
    SCRIPT_A := random 0x00
    jump script-step
: do-op-inc
    SCRIPT_A  += 1
    if SCRIPT_A == 0 then SCRIPT_A := 255 # saturating inc
    jump script-step
: do-op-dec
    SCRIPT_A  += -1
    if SCRIPT_A == 255 then SCRIPT_A := 0 # saturating dec
    jump script-step
: do-op-load
    script-addr
    load SCRIPT_A - SCRIPT_A
    jump script-step
: do-op-save
    script-addr
    save SCRIPT_A - SCRIPT_A
    jump script-step
: do-op-table
    script-addr
    i += SCRIPT_A
    load v0 - v1
    jump script-exec
: do-op-jumpif
    if SCRIPT_A == 0 then SCRIPT_PC += 2
    if SCRIPT_A == 0 then jump script-step
    # otherwise, fall through...
: do-op-jump
    script-addr
    v0 := v1
    v1 := v2
    jump script-exec
: script-addr
    i := script-addr-slot
    save v1 - v2
    SCRIPT_PC += 2
    indirect script-addr-slot
;

And then to compose a script, we can write a series of macros for gluing together opcodes and arguments:

:macro opcode OP { :byte { OP - script-dispatch } }
:macro s-end         { opcode do-op-end                  }
:macro s-clear       { opcode do-op-clear                }
:macro s-pause       { opcode do-op-pause                }
:macro s-const    NN { opcode do-op-const   :byte NN     } 
:macro s-random   NN { opcode do-op-const   :byte NN     }
:macro s-inc-a       { opcode do-op-inc                  }
:macro s-dec-a       { opcode do-op-dec                  }
:macro s-load   ADDR { opcode do-op-load    pointer ADDR }
:macro s-save   ADDR { opcode do-op-save    pointer ADDR }
:macro s-table  ADDR { opcode do-op-tab     pointer ADDR }
:macro s-jumpif ADDR { opcode do-op-jumpif  pointer ADDR }
:macro s-jump   ADDR { opcode do-op-jump    pointer ADDR }
:macro script   ADDR { unpack16 ADDR script-exec }

Given all this machinery, a trivial script and its call site might look something like the following:

to-data
: temp-1 0xAA
: temp-2 0x00
: demo-script
    s-clear
    s-load temp-1
    s-inc-a
    s-save temp-2
    s-end
to-code
: main
    script demo-script
    i := 0xABC
    :monitor temp-1 2
    :breakpoint done

Invoking a script only costs 6 bytes (4 to stash a 16-bit pointer in a pair of v registers, and one call to script-exec), and then the only cost is data ram. The interpreter above can carry out complex control flow between script fragments (each fragment limited to a 256 byte "page", which is plenty for a basic block), and could drive whole conversation trees with the addition of, say, some opcodes for initializing and displaying menus. The whole system described above can be tried out in Octo itself here:

http://octo-ide.com/?key=CHxsqsx7

If you're interested, try contrasting this interpreter with the less general one in Business is Contagious. You can also read a more game-design-oriented devlog on the community forum for the jam. Have any thoughts? What strategies have you used to make the best use of XO-Chip ram?

Get Octo

Leave a comment

Log in with itch.io to leave a comment.