Creating a game with gbforth

It’s been a while since I last worked on gbforth, but I felt like the project was left a little unfinished due to lack of information on how to get started. So I decided to write at least one guide that aims to create a simple game with the toolkit from scratch.

It took me some time to think of a game that is easy enough to start with, but I eventually settled on Simon: This game generates a sequence of sounds and asks the player to repeat them. Writing a game like this means you get to learn about player input, displaying things on the screen, playing sounds, and generating random values. A good start for the aspiring gbforth developer!

To keep things simple, I covered the essential parts only, with some suggestions of future improvements at the end:

  1. Playing music notes
  2. Generating a pattern
  3. Showing the pattern
  4. Verifying player input
  5. Keeping track of score
  6. Feature suggestions

Playing music notes

Start by creating a new file simon.fs to write our game in. We can start with playing music notes first. Let’s import the music.fs lib to help with that:

require music.fs

This lib does not require any initialisation before using, and we directly have access to a bunch of notes (from C3 to B8) and the note word to play them. You can try it out by playing a couple of notes in the main definition:

require music.fs

: main
  C#5 note
  E5  note
  A5  note
  E6  note ;

If you compile this ROM and run it, you’ll notice that you can only hear a single sound. This is because the 4 notes are played at almost the same time. To make it sound properly, we need to delay some of the notes. For this we can use the time.fs lib:

require music.fs
require time.fs

: main
  C#5 note 500 ms
  E5  note 500 ms
  A5  note 500 ms
  E6  note ;

The ms word will wait a number of milliseconds before continuing. With this addition you should be able to hear 4 distinguishable notes. Feel free to spend some time playing with a couple of other notes or even trying to create a simple melody!

Generating a pattern

The next step would be to generate a pattern of notes. To do this, we first need to know how to generate a random note, so our game will not be too predictable. After that, we need to save the pattern somewhere so we can verify that the player remembers it correctly.

Random notes

To be able to generate random values, we will include the random.fs lib, and set an initial seed. For now, you can just store an arbitrary number (except for 0) to the seed variable:

require random.fs

: main
  1234 seed ! ;

After this, you can use the word random to generate random numbers. Let’s define a new word random-note first to try this out:

: random-note ( -- n )
  4 random ;

Now we can add a CASE construction to map this number to a note:

: random-note ( -- n )
  4 random CASE
    0 OF C#5 ENDOF
    1 OF E5  ENDOF
    2 OF A5  ENDOF
    3 OF E6  ENDOF
  ENDCASE ;

Playing a single note isn’t too exciting, so we can use ?DO/LOOP to play a random note a few times:

require random.fs
require music.fs
require time.fs

: random-note ( -- n )
  4 random CASE
    0 OF C#5 ENDOF
    1 OF E5  ENDOF
    2 OF A5  ENDOF
    3 OF E6  ENDOF
  ENDCASE ;

: main
  1234 seed !
  10 0 ?DO
    random-note note
    500 ms
  LOOP ;

Running this should play 10 random tones in succession. Give it a try!

Storing the pattern

To remember the pattern, we need to reserve some space in the RAM to store it in. First we use CREATE to define the memory space:

CREATE pattern
10 cells allot

The first line will define the word pattern, which pushes the memory address of itself to the stack upon calling. The second line reserves 10 cells in the memory for us to store our pattern in.

To save the first note, we can simply store it at the address returned by pattern. Let’s define a new word generate-pattern to do this:

: generate-pattern ( -- )
  random-note pattern ! ;

The second note needs to be written to the following cell, and the third note to the cell following that one:

: generate-pattern ( -- )
  random-note pattern           !
  random-note pattern 1 cells + !
  random-note pattern 2 cells + ! ;

You can probably spot the pattern here already. To make things a bit nicer, we will use the loop from before to fill the pattern with notes completely:

: generate-pattern ( -- )
  10 0 ?DO
    random-note pattern I cells + !
  LOOP ;

The word I pushes the index of the loop on top of the stack (09), so this code will fill our pattern with 10 random notes.

We can now write a very similar word play-pattern as well:

: play-pattern ( -- )
  10 0 ?DO
    pattern I cells + @
    note
    500 ms
  LOOP ;

Rather than storing notes, this will fetch a note from the pattern, play it, and wait 500 ms before playing the next one.

Our complete code will now look something like this:

require random.fs
require music.fs
require time.fs

CREATE pattern
10 cells allot

: random-note ( -- n )
  4 random CASE
    0 OF C#5 ENDOF
    1 OF E5  ENDOF
    2 OF A5  ENDOF
    3 OF E6  ENDOF
  ENDCASE ;

: generate-pattern ( -- )
  10 0 ?DO
    random-note pattern I cells + !
  LOOP ;

: play-pattern ( -- )
  10 0 ?DO
    pattern I cells + @
    note
    500 ms
  LOOP ;

: main
  1234 seed !
  generate-pattern
  play-pattern ;

Showing the pattern

Now we have generated a random pattern and are able to play it, we should also add some visual feedback. Without this, the player would not know which buttons to press. We can use the ibm-font.fs and the term.fs lib to show a minimalistic UI. Include the libs and initialise them first:

require ibm-font.fs
require term.fs

: main
  install-font
  init-term ;

Then we can define a new word that shows which key to press for a given note:

: .note ( n -- )
  CASE
    C#5 OF 24 ENDOF
    E5  OF 25 ENDOF
    A5  OF 26 ENDOF
    E6  OF 27 ENDOF
  ENDCASE
  emit ;

Depending on the note, this word will emit a character to the screen. The numbers 24 to 27 are are character codes that correspond to the arrow symbols in the ibm-font.

Now we can add this word to our play-pattern definition:

: play-pattern ( -- )
  10 0 ?DO
    pattern I cells + @
    dup
    9 8 at-xy .note
    note
    500 ms
  LOOP ;

We introduce dup as well to duplicate the note value, so we can pass it to both the .note and note word. The 9 8 at-xy part ensures we display the arrow symbol at roughly the centre of the screen, and that the previous gets overwritten (by always emitting to the same coordinates).

The full code so far:

require ibm-font.fs
require term.fs
require random.fs
require music.fs
require time.fs

CREATE pattern
10 cells allot

: random-note ( -- n )
  4 random CASE
    0 OF C#5 ENDOF
    1 OF E5  ENDOF
    2 OF A5  ENDOF
    3 OF E6  ENDOF
  ENDCASE ;

: generate-pattern ( -- )
  10 0 ?DO
    random-note pattern I cells + !
  LOOP ;

: .note ( n -- )
  CASE
    C#5 OF 24 ENDOF
    E5  OF 25 ENDOF
    A5  OF 26 ENDOF
    E6  OF 27 ENDOF
  ENDCASE
  emit ;

: play-pattern ( -- )
  10 0 ?DO
    pattern I cells + @
    dup
    9 8 at-xy .note
    note
    500 ms
  LOOP ;

: main
  install-font
  init-term
  1234 seed !
  generate-pattern
  play-pattern ;

Verifying player input

Now we’re ready to ask the player for input! For this we’ll need the help of the input.fs lib:

require input.fs

: main
  init-input ;

The init-input word makes sure the system is ready to accept key presses, which we can get with the word key. We need to add a new word to convert a key code to a note. It’s a bit similar to the .note word we wrote earlier:

: key>note ( c -- n )
  CASE
    k-up    OF C#5 ENDOF
    k-down  OF E5  ENDOF
    k-right OF A5  ENDOF
    k-left  OF E6  ENDOF
               G3
  ENDCASE ;

In this word we are mapping from key code (k-upk-left) to a note, making sure that the keys are corresponding to the correct character codes we used in .note.

We are also adding an extra note at the end of our 4 OFENDOF cases. We do this because we want to return a different note if the player presses a key that is not one of the joy pad keys. In this case G3, which is not a note that can occur in the generated pattern (so this is always considered incorrect).

Let’s define a new word that repeatedly checks for player input, and plays the corresponding note:

: prompt-pattern ( -- )
  10 0 ?DO
    key key>note note
  LOOP ;

This will play 10 notes depending on what buttons you press. We also need to verify that the entered notes match the pattern:

: prompt-pattern ( -- )
  10 0 ?DO
    key key>note
    dup note
    pattern I cells + @
    <> IF ." Game Over" bye THEN
  LOOP ;

We introduce dup again so we have a copy of the entered note. Then note will play the note and consume the copied value.

After playing the entered note, we fetch the I-th note from the pattern (using the same line as in play-pattern), and compare it to the entered note with the word <>. This word compares 2 values, and returns true when they are not equal to each other (in this case, if the player entered a wrong note).

In the IF/THEN block that follows the comparison, we display “Game Over” to the screen. The word bye will stop the program entirely, so no more notes can be entered once the player has lost.

Let’s add the input handling to the rest of our code:

require ibm-font.fs
require term.fs
require input.fs
require random.fs
require music.fs
require time.fs

CREATE pattern
10 cells allot

: random-note ( -- n )
  4 random CASE
    0 OF C#5 ENDOF
    1 OF E5  ENDOF
    2 OF A5  ENDOF
    3 OF E6  ENDOF
  ENDCASE ;

: generate-pattern ( -- )
  10 0 ?DO
    random-note pattern I cells + !
  LOOP ;

: .note ( n -- )
  CASE
    C#5 OF 24 ENDOF
    E5  OF 25 ENDOF
    A5  OF 26 ENDOF
    E6  OF 27 ENDOF
  ENDCASE
  emit ;

: play-pattern ( -- )
  10 0 ?DO
    pattern I cells + @
    dup
    9 8 at-xy .note
    note
    500 ms
  LOOP ;

: key>note ( c -- n )
  CASE
    k-up    OF C#5 ENDOF
    k-down  OF E5  ENDOF
    k-right OF A5  ENDOF
    k-left  OF E6  ENDOF
               G3
  ENDCASE ;

: prompt-pattern ( -- )
  10 0 ?DO
    key key>note
    dup note
    pattern I cells + @
    <> IF ." Game Over" bye THEN
  LOOP ;

: main
  install-font
  init-term
  init-input
  1234 seed !
  generate-pattern
  play-pattern
  prompt-pattern ;

Keeping track of score

If you run the code that we wrote so far, you’ll notice that we are pretty far already: A melody is generated and presented to the player, and the player can enter the pattern which is also verified.

To turn this into a proper game, we need to make the pattern increase in length 1 note at a time, rather than playing 10 notes from the beginning. A quick way to do this, is by parameterising the play-pattern and prompt-pattern: Remove the 10 at the start of the definition of both words, and move them to the main word:

: play-pattern ( u -- )
  0 ?DO
  ( ... ) ;

: prompt-pattern ( u -- )
  0 ?DO
  ( ... ) ;

: main
  ( ... )
  10 play-pattern
  10 prompt-pattern ;

Due to the concatenative nature of Forth, this code still does the same thing. The big difference is that we can now tweak the amount of notes we want to play and prompt. To start with a single note, and increase the length every round, we can use a loop:

: main
  ( ... )
  11 1 ?DO
    I play-pattern
    I prompt-pattern
  LOOP ;

This way we call both words repeatedly, passing the value 1 to 10 to indicate the current pattern length.

At the end of each round, we should probably show the current score:

: main
  ( ... )
  11 1 ?DO
    I play-pattern
    I prompt-pattern
    2 2 at-xy
    ." Score: "
    I .
  LOOP ;

First we move the cursor to coordinates (2,2), and display a string to the player. Then we use I to get the current loop index (which is the current round), and display the number as text with the . word.

It would also be a good idea to add a message for when the player completed all 10 rounds:

: main
  ( ... )
  11 1 ?DO
    I play-pattern
    I prompt-pattern
    2 2 at-xy
    ." Score: "
    I .
  LOOP
  2 3 at-xy ." You win!" ;

And that’s it! We have finished creating a very simple game for the Game Boy. There is a lot of room for improvement still, but it has a basic game loop, score system, and win/lose condition. Our final code looks like this:

require ibm-font.fs
require term.fs
require input.fs
require random.fs
require music.fs
require time.fs

CREATE pattern
10 cells allot

: random-note ( -- n )
  4 random CASE
    0 OF C#5 ENDOF
    1 OF E5  ENDOF
    2 OF A5  ENDOF
    3 OF E6  ENDOF
  ENDCASE ;

: generate-pattern ( -- )
  10 0 ?DO
    random-note pattern I cells + !
  LOOP ;

: .note ( n -- )
  CASE
    C#5 OF 24 ENDOF
    E5  OF 25 ENDOF
    A5  OF 26 ENDOF
    E6  OF 27 ENDOF
  ENDCASE
  emit ;

: play-pattern ( u -- )
  0 ?DO
    pattern I cells + @
    dup
    9 8 at-xy .note
    note
    500 ms
  LOOP ;

: key>note ( c -- n )
  CASE
    k-up    OF C#5 ENDOF
    k-down  OF E5  ENDOF
    k-right OF A5  ENDOF
    k-left  OF E6  ENDOF
               G3
  ENDCASE ;

: prompt-pattern ( u -- )
  0 ?DO
    key key>note
    dup note
    pattern I cells + @
    <> IF ." Game Over" bye THEN
  LOOP ;

: main
  install-font
  init-term
  init-input
  1234 seed !
  generate-pattern
  11 1 ?DO
    I play-pattern
    I prompt-pattern
    2 2 at-xy
    ." Score: "
    I .
  LOOP
  2 3 at-xy ." You win!" ;

Feature suggestions

If you followed along every step, you should now have a minimalistic but functioning game. A good way to learn more is by trying to implement some extra features or improvements yourself. Here are some suggestions (with increasing difficulty) to work on next:

  • The game is not aesthetically pleasing at all. You can use at-xy and page to improve the UI rendering, and ms to delay some state transitions a bit.
  • For some players 10 rounds might be a bit too easy to beat. You could increase this to 20, or even 100.
  • Still not challenging enough? You could add more variety to the pattern by introducing 2 extra notes and using the k-a and k-b keys.
  • Because we use a constant number as the initial seed, the pattern is not really random. One approach to improve the randomness is to add a title screen, wait for a key press, and use utime as the initial seed.
  • After you win or lose, there is no way to play the game again other than restarting the device completely. You could improve this by restarting the game loop after it has finished, or even adding a proper menu.