Let’s make a Snake game on a Melopero Cookie RP2040

The Melopero Cookie RP2040 is a nice development board made by Melopero Electronics, with a Raspberry Pico RP2040 chip on board, a 5×5 NeoPixel grid and a couple of I/O buttons (plus a reset and boot select button which come in handy when flashing the board with u2f firmware). As soon as I got one my first thought was to make a small game for it, and since the display is a square array of 25 NeoPixels I thought it would be a nice idea to add some feedback by displaying some messages on it. Let’s see how.

Using the onboard LED

There’s a LED on the Melopero Cookie that can be set or cleared calling SetLed():

class Cookie
{
public:

    void SetLED(bool on)
    {
        gpio_put(LED_PIN, on);
    }

    // other code...

};

Displaying messages and drawing on the NeoPixel display

The Pico has two PIOs peripherals and a DMA controller, so I used both to display pixels on the NeoPixel array. The PIO program takes a 32-bit word from the configured state machine’s FIFO and sends it with appropriate timings to the NeoPixel  display; the PIO’s FIFO is fed by the DMA controller, that is configured to send 25 32-bit words at a time. When a message is sent, a buffer is filled with data to display the message (the scrolling direction matters, there’s a case for each of the four scrolling directions), the DMA is configured to read from the buffer and a timer is started to control the scrolling speed. A timer callback is installed, which is called every time the timer triggers, and updates the address from which the DMA reads:

class Cookie
{
public:

    void ShowMessage(const std::string &message, uint32_t timeMS = 200, enum MessageDirection = MessageDirection::DOWN)
    {
        mMessage = message;

        mBuffer.clear();
        mBuffer.resize((message.size() + 2) * WORDS_PER_CHAR);
 
        switch (mMessageDirection)
        {
            case MessageDirection::UP:  

                for (int i = 0; i < message.size(); i++)
                {
                    const uint8_t (&character)[CHAR_WIDTH] = glyphs[message[i] - 32];
                    //const uint8_t *character = glyphs[message[i] - 32];  

                    for (int row = 0; row < CHAR_HEIGHT; row++)
                        for (int col = 0; col < CHAR_WIDTH; col++)
                        {
                            uint8_t characterCol = character[col]; 
                            uint32_t color = characterCol & 1 << (CHAR_HEIGHT - 1) - row ? CHAR_COL : BACKGROUND_COL;
                            mBuffer[row * CHAR_WIDTH + col + (i + 1) * WORDS_PER_CHAR] = color;
                        }      
                }

                mDataIndex = 0;

                break;

            case MessageDirection::DOWN:
            {
                std::string reversedMessage = ReverseMessage(message);
                
                for (int i = 0; i < reversedMessage.size(); i++)
                {
                    const uint8_t (&character)[CHAR_WIDTH] = glyphs[reversedMessage[i] - 32];

                    for (int row = 0; row < CHAR_HEIGHT; row++)
                        for (int col = 0; col < CHAR_WIDTH; col++)
                        {
                            uint8_t characterCol = character[col]; 
                            uint32_t color = characterCol & 1 << (CHAR_HEIGHT - 1) - row ? CHAR_COL : BACKGROUND_COL;
                            mBuffer[row * CHAR_WIDTH + col + (i + 1) * WORDS_PER_CHAR] = color;
                        } 
                }
                
                mDataIndex = (reversedMessage.size() + 1) * WORDS_PER_CHAR;

                break;
            }    
                
            case MessageDirection::LEFT:

                for (int i = 0; i < message.size(); i++)
                {
                    const uint8_t (&character)[CHAR_WIDTH] = glyphs[message[i] - 32];

                    for (int row = 0; row < CHAR_HEIGHT; row++)
                        for (int col = 0; col < CHAR_WIDTH; col++)
                        {
                            uint8_t characterCol = character[row]; 
                            uint32_t color = characterCol & 1 << col ? CHAR_COL : BACKGROUND_COL;
                            mBuffer[row * CHAR_WIDTH + col + (i + 1) * WORDS_PER_CHAR] = color;
                        } 
                }

                mFrameRow = 0;

                break;

            case MessageDirection::RIGHT:
            {
                std::string reversedMessage = ReverseMessage(message);

                for (int i = 0; i < reversedMessage.size(); i++)
                {
                    const uint8_t (&character)[CHAR_WIDTH] = glyphs[reversedMessage[i] - 32];
                    
                    for (int row = 0; row < CHAR_HEIGHT; row++)
                        for (int col = 0; col < CHAR_WIDTH; col++)
                        {
                            uint8_t characterCol = character[row]; 
                            uint32_t color = characterCol & 1 << col ? CHAR_COL : BACKGROUND_COL;
                            mBuffer[row * CHAR_WIDTH + col + (i + 1) * WORDS_PER_CHAR] = color;
                        } 
                }

                mFrameRow = (reversedMessage.size() + 1) * CHAR_WIDTH; 

                break;
            }
        }

        // fill first and last empty character
        for (int row = 0; row < CHAR_HEIGHT; row++)
            for (int col = 0; col < CHAR_WIDTH; col++)
            {
                mBuffer[row * CHAR_WIDTH + col] = BACKGROUND_COL;
                mBuffer[row * CHAR_WIDTH + col + (mMessage.size() + 1) * WORDS_PER_CHAR] = BACKGROUND_COL;
            } 
 
        mIsShowingMessage = true;
        bIsTimerDone = false;
        SetupAndStartTimer(timeMS);
    }
  
    // other code...

private:

    void SetupAndStartTimer(uint32_t timeMS)
    {
        add_repeating_timer_ms(timeMS, repeating_timer_callback, this, &mTimer);
    }

    // other code...

};

bool repeating_timer_callback(repeating_timer_t *rt)
{
    if (!((Cookie *)(rt->user_data))->mIsShowingMessage)
    {
        ((Cookie *)(rt->user_data))->bIsTimerDone = true;
        return false;
    }

    switch (((Cookie*)(rt->user_data))->mMessageDirection)
    {
        case Cookie::MessageDirection::UP:

            if (((Cookie *)(rt->user_data))->mDataIndex >= (((Cookie *)(rt->user_data))->mMessage.size() + 1) * WORDS_PER_CHAR)  
                ((Cookie *)(rt->user_data))->mDataIndex = 0;

            dma_channel_set_read_addr(((Cookie*)(rt->user_data))->mDMAChannel, ((Cookie*)(rt->user_data))->mBuffer.data() + ((Cookie*)(rt->user_data))->mDataIndex, true);
            
            ((Cookie *)(rt->user_data))->mDataIndex += CHAR_WIDTH;

            break;

        case Cookie::MessageDirection::DOWN:

            if (((Cookie *)(rt->user_data))->mDataIndex <= 0)
                ((Cookie *)(rt->user_data))->mDataIndex = (((Cookie *)(rt->user_data))->mMessage.size() + 1) * WORDS_PER_CHAR; 

            dma_channel_set_read_addr(((Cookie*)(rt->user_data))->mDMAChannel, ((Cookie*)(rt->user_data))->mBuffer.data() + ((Cookie*)(rt->user_data))->mDataIndex, true);
            
            ((Cookie *)(rt->user_data))->mDataIndex -= CHAR_WIDTH;
            
            break;

        case Cookie::MessageDirection::LEFT:

            if (((Cookie*)(rt->user_data))->mFrameRow >= (((Cookie*)(rt->user_data))->mMessage.size() + 1) * CHAR_WIDTH)
                ((Cookie*)(rt->user_data))->mFrameRow = 0;

            ((Cookie*)(rt->user_data))->RemapBuffer();
            dma_channel_set_read_addr(((Cookie*)(rt->user_data))->mDMAChannel, ((Cookie*)(rt->user_data))->mFrame, true);
            
            ((Cookie*)(rt->user_data))->mFrameRow++;
            
            break;

        case Cookie::MessageDirection::RIGHT:

            if (((Cookie*)(rt->user_data))->mFrameRow <= 0)
                ((Cookie*)(rt->user_data))->mFrameRow = (((Cookie*)(rt->user_data))->mMessage.size() + 1) * CHAR_WIDTH;

            ((Cookie*)(rt->user_data))->RemapBuffer();
            dma_channel_set_read_addr(((Cookie*)(rt->user_data))->mDMAChannel, ((Cookie*)(rt->user_data))->mFrame, true);
            
            ((Cookie*)(rt->user_data))->mFrameRow--;
            
            break;
    }

    return true;
}

Calling ShowMessage() with the message to display shows the message on the display, with the direction set by a call to SetMessageDirection()  (Some row and column manipulations are performed and an additional framebuffer is used in order to display the text based on the direction choosed).

Drawing on the display is also possible, by calling StopMessage() (if any message is being currently shown on the display), and using ClearDisplay(), SetPixel() and ShowDisplay() functions:

class Cookie:
{
public:

    // other code...   

    void SetPixel(uint8_t x, uint8_t y, uint8_t red, uint8_t green, uint8_t blue)
    {
        mFrame[y * 5 + x] = FormatColor(red, green, blue);
    }

    void ClearDisplay(uint8_t red, uint8_t green, uint8_t blue)
    {
        for (uint8_t i = 0; i < WORDS_PER_CHAR; i++)
            mFrame[i] = FormatColor(red, green, blue);
    }

    void ShowDisplay()
    {
        while (!bIsTimerDone)
            ;

        dma_channel_set_read_addr(mDMAChannel, mFrame, true);
    }

    // other code...

};

Getting input: polling vs event driven

When it comes to get input from a device, two approaches are viable: continuously polling the device for input, or letting the device generate events whenever it wants to send input to the processor via interrupts. The Cookie imlements both ways, for getting input from the two buttons: you can call the function IsButtonPressed() with the name of the button (an enum called Button, A or B), which returns if the specified button has been pressed. This public function calls a private helper function, GetButtonState(), that checks the current state of the button, based on the button state during the previous loop (the state is stored into the private data members mButtonStateA and mButtonStateB); the button can be in one of four states, represented in the ButtonState enum: it can be just pressed, kept pressed, just released or kept released (other functions can be implemented using this helper function, to check if the button ha been just released, for example):

class Cookie
{
public:
    
    // other code...

    enum class Button
    {
        A, B,
    };

    bool IsButtonPressed(Button button)  
    {
        return GetButtonState(button) == ButtonState::JUST_PRESSED;
    }

private:

    enum class ButtonState
    {
        JUST_RELEASED, RELEASED, JUST_PRESSED, PRESSED,
    } mButtonStateA = ButtonState::RELEASED, mButtonStateB = ButtonState::RELEASED;

    ButtonState GetButtonState(Button button)
    {
        switch (button)
        {
            case Button::A:
                mButtonStateA = gpio_get(BUTTON_A) ? (mButtonStateA == ButtonState::RELEASED ? ButtonState::JUST_PRESSED : ButtonState::PRESSED) : (mButtonStateA == ButtonState::PRESSED ? ButtonState::JUST_RELEASED : ButtonState::RELEASED);
                return mButtonStateA;
                break;

            case Button::B:
                mButtonStateB = gpio_get(BUTTON_B) ? (mButtonStateB == ButtonState::RELEASED ? ButtonState::JUST_PRESSED : ButtonState::PRESSED) : (mButtonStateB == ButtonState::PRESSED ? ButtonState::JUST_RELEASED : ButtonState::RELEASED);
                return mButtonStateB;
                break;

            default:
                return ButtonState::RELEASED;
                break;
        }
    }

    // other code...

};

Calling IsButtonPressed() returns if the button is currently pressed at the time the function is called, so we check for the state of the button in each loop; doing so can lead to miss some button presses, expecially if the loop is busy doing other things, such as sleeping for any amount of time. This is where interrupts and events come in.

Interrupt-driven events cannot be missed, since every button press or release triggers an interrupt, which generates the corresponding event; this event is stored in an event queue and at the beginning of each loop we retrieve the occurred events from the queue. The queue is a FIFO, so the order in which each event occurred is preserved:

enum class InputEvent : uint8_t
{
    BUTTON_A_JUST_RELEASED, BUTTON_A_JUST_PRESSED,
    BUTTON_B_JUST_RELEASED, BUTTON_B_JUST_PRESSED,
    NULL_EVENT,
};

struct InputEventQueue  // ring buffer
{
    static const uint8_t MAX_NUM_INPUT_EVENTS = 10;
    InputEvent mQueue[MAX_NUM_INPUT_EVENTS];
    int8_t mHead = 0;
    int8_t mTail = 0;

    void Insert(InputEvent event)
    {
        if (!IsFull())
        {
            mQueue[mTail++] = event;
            mTail %= MAX_NUM_INPUT_EVENTS;
        }
    }

    InputEvent Get()
    {
        if (!IsEmpty())
        {   
            InputEvent event = mQueue[mHead++];
            mHead %= MAX_NUM_INPUT_EVENTS;

            return event;
        }
        else
            return InputEvent::NULL_EVENT;
    }

    bool IsEmpty() const
    {
        return mHead == mTail;
    }

    bool IsFull() const
    {
        return (mTail + 1) % MAX_NUM_INPUT_EVENTS == mHead;
    }

};

static InputEventQueue inputEventQueue;

class Cookie
{
    // other code...

private:

    void InitButtons()
    {
        gpio_init(BUTTON_A);
        gpio_set_dir(BUTTON_A, GPIO_IN);
        gpio_set_irq_enabled(BUTTON_A, GPIO_IRQ_EDGE_RISE | GPIO_IRQ_EDGE_FALL, true);

        gpio_init(BUTTON_B);
        gpio_set_dir(BUTTON_B, GPIO_IN);
        gpio_set_irq_enabled(BUTTON_B, GPIO_IRQ_EDGE_RISE | GPIO_IRQ_EDGE_FALL, true);

        gpio_set_irq_callback(&gpio_callback);

        irq_set_enabled(IO_IRQ_BANK0, true);
    }

    // other code...

};

void gpio_callback(uint gpio, uint32_t event_mask)
{
    switch (gpio)
    {
        case BUTTON_A:
            if (event_mask & GPIO_IRQ_EDGE_RISE)
                inputEventQueue.Insert(InputEvent::BUTTON_A_JUST_PRESSED);
            else if (event_mask & GPIO_IRQ_EDGE_FALL)
                inputEventQueue.Insert(InputEvent::BUTTON_A_JUST_RELEASED);
            break;

        case BUTTON_B:
            if (event_mask & GPIO_IRQ_EDGE_RISE)
                inputEventQueue.Insert(InputEvent::BUTTON_B_JUST_PRESSED);
            else if (event_mask & GPIO_IRQ_EDGE_FALL)
                inputEventQueue.Insert(InputEvent::BUTTON_B_JUST_RELEASED);
            break;

        //gpio_acknowledge_irq(gpio, event_mask);  // called automatically
    }
}
};

To get an event we can call Get() on the instance of the event queue inputEventQueue. When the GPIO interrupt service routine triggers, it stores the relevant event into the queue calling Insert(). In this way, asynchronous input events are latched into the FIFO and retrieved when needed, even when the loop is busy tending to other devices or sleeping.

while(true) 
{    
       // other code...
       
       // process input 

        sleep_ms(1000);

        InputEvent event = InputEvent::NULL_EVENT;
        while (!inputEventQueue.IsEmpty())
            event = inputEventQueue.Get();          // use events

        if (event == InputEvent::BUTTON_B_JUST_RELEASED)
        {
            // perform event logic
        }
            
        if (event == InputEvent::BUTTON_A_JUST_RELEASED)
        {
           // perform event logic
        }

        // other code...

}

Making a Snake game on the Melopero Cookie

This little framework can be used to make a game, displayed on the NeoPixel array and controlled by the two buttons on the board. I used it to make a simple Snake clone: a state machine controls the state of the game and when in the play state the buttons are used to control the direction of the snake; when the game ends, a game over message is displayed on the screen, and pressing the A button resets the game:

bool IsInSnake(uint8_t x, uint8_t y, bool head = false);

int appleX, appleY;
std::vector<std::array<uint8_t, 2>> snake = { {2, 1}, {3, 1} };
int8_t snakeDirection = 2;   // UP = 0, LEFT = 1, DOWN = 2, RIGHT = 3

int main(void)
{
    Cookie cookie;

    enum class GameState
    {
        PLAY, GAME_OVER,
    } gameState = GameState::PLAY;

    // set the initial position of the fruit
    do
    {
        appleX = rand() % 5;
        appleY = rand() % 5;
    } while (IsInSnake(appleX, appleY));

    while(true) 
    {    
        switch (gameState)
        {
            case GameState::PLAY:
            {
                // process input 
                sleep_ms(1000);

                InputEvent event = InputEvent::NULL_EVENT;
                while (!inputEventQueue.IsEmpty())
                    event = inputEventQueue.Get();          // use events

                if (event == InputEvent::BUTTON_B_JUST_RELEASED)
                {
                    snakeDirection--;
                    if (snakeDirection < 0)
                        snakeDirection = 3;
                }
            
                if (event == InputEvent::BUTTON_A_JUST_RELEASED)
                {
                    snakeDirection++;
                    snakeDirection %= 4;
                }


                // update state
                switch (snakeDirection)
                {
                    case 0:  // UP
                        snake.insert(snake.begin(), { snake[0][0], uint8_t(snake[0][1] - 1) });
                        break;

                    case 1:  // LEFT
                        snake.insert(snake.begin(), { (uint8_t)(snake[0][0] - 1), snake[0][1] });
                        break;

                    case 2:  // DOWN
                        snake.insert(snake.begin(), { snake[0][0], (uint8_t)(snake[0][1] + 1) });
                        break;

                    case 3:  // RIGHT
                        snake.insert(snake.begin(), { (uint8_t)(snake[0][0] + 1), snake[0][1] });
                        break;
                }

                if (!IsInSnake(appleX, appleY))
                    snake.erase(snake.end() - 1);
                else
                    do
                    {
                        appleX = rand() % 5;
                        appleY = rand() % 5;
                    } while (IsInSnake(appleX, appleY));

                if (snake[0][0] < 0 || snake[0][0] > 4 || snake[0][1] < 0 || snake[0][1] > 4 || IsInSnake(snake[0][0], snake[0][1], true))  // change state
                {
                    cookie.ClearDisplay(0x08, 0x00, 0x00);
                    cookie.ShowDisplay();

                    cookie.SetMessageDirection(Cookie::MessageDirection::LEFT);
                    cookie.ShowMessage("game over - play again");
                    
                    gameState = GameState::GAME_OVER;
                    break;
                }

                // render game
                cookie.ClearDisplay(0x00, 0x00, 0x08);

                // draw apple
                cookie.SetPixel(appleX, appleY, 0x08, 0x00, 0x00);  

                // draw snake
                for (uint8_t i = 0; i < snake.size(); i++)
                    cookie.SetPixel(snake[i][0], snake[i][1], 0x08, 0x08, 0x08); 

                cookie.ShowDisplay();

                break;
            }
            case GameState::GAME_OVER:
                if (cookie.IsButtonPressed(Cookie::Button::A))  // use polling - change state
                {
                    cookie.StopMessage();

                    snake.clear();
                    snake.push_back({2, 0});
                    snake.push_back({3, 0}); 

                    do
                    {
                        appleX = rand() % 5;
                        appleY = rand() % 5;
                    } while (IsInSnake(appleX, appleY));

                    gameState = GameState::PLAY;    
                }

                break;
        }
    }    
}

bool IsInSnake(uint8_t x, uint8_t y, bool head)
{
    bool first = true;
    for (auto it = snake.begin(); it != snake.end(); ++it)
        if (x == (*it)[0] && y == (*it)[1])
            if (first && head) 
            {
                first = false;
                continue;
            }
            else
                return true;

    return false;
}

and this is the final result:

 

Leave a Reply

Your email address will not be published. Required fields are marked *