RingoSki - A skiing game for Ringo

It has been about a month in the making, but I’m happy to announce the release of my first game for Ringo, RingoSki!

RingoSki is somewhat derived from my MAKERbuino game Skibuino, but I wouldn’t call it a port as it has many new features and required significant changes for the Ringo. Both RingoSki and Skibuino use techniques from a game engine I developed in C about a decade ago. Inspiration comes from the 1991 game SkiFree for Windows and DOS.

The GitHub repo is located here for source code and release downloads: https://github.com/delpozzo/ringoski

If you want to directly download the release, you can simply click here: RingoSki-1.0.0.zip.

ringoski-1 ringoski-2 ringoski-3 ringoski-4 ringoski-5 ringoski-6 ringoski-7 ringoski-8 ringoski-9 ringoski-10 ringoski-11 ringoski-12

Installation
Step 1: Download RingoSki-1.0.0.zip from the release folder.
Step 2: Unzip the RingoSki-1.0.0.zip archive.
Step 3: Copy the resulting RingoSki folder to the root directory of your Ringo’s SD card.

Controls
Joystick L/R - Ski to the left or right.
A Button - Increase speed. Turing while this button is held results in a power slide.
B Button - Jump. Used to jump over logs.
Function L/R - Pause game (these are the buttons next to the power and home buttons).
0 - Debug mode. Draws bounding boxes and some debug information.

Gameplay
• Avoid trees and penguins by using Joystick L/R to ski to the left and right.
• Hold down A to increase your speed, this is essential to outrun the Yeti.
• Turning while holding A results in a power slide to quickly maneuver around obstacles.
• Jump over logs by pressing B.
• The game ends when the Yeti catches you.
• Your score is based off of the amount of meters traveled.
• The map is randomly generated for a unique experience each time.

----------------------------------------------------

Lessons Learned / Tips for Ringo Developers

First off, I want to say that I had an absolute blast developing for the Ringo. The ESP32 SoC with Xtensa microprocessor that powers it is extremely capable. A full blown retro-RPG title would definitely be possible on this platform if developed properly. With that said, there were some challenges I encountered along the way:

SD Card I/O and Sound FX
Care must be taken when reading/writing to/from the SD card. The current MAKERphone library only allows you to have 4 MPTracks loaded at any given point in time.

I found out the hard way that reading/writing files to the SD card (for instance, when saving or reading a high score) also shares one of these 4 “slots” with MPTrack. By observing the serial output, I noticed the following error when this condition happened: vfs_fat: open: no free file descriptors. I was able to solve this problem by unloading all sounds whenever I needed to save or read the high score, followed by reloading the sounds after the file operation completed.

initSounds() function:

void initSounds()
{
  // Load menu sounds if at the menu
  if(activeLoop == MENULOOP)
  {
    sndMenuMusic = new MPTrack("/RingoSki/menu.wav");
    sndMenuMusic->setVolume(map(mp.mediaVolume, 0, 14, 100, 300));
    addTrack(sndMenuMusic);

    sndStart = new MPTrack("/RingoSki/start.wav");
    sndStart->setVolume(map(mp.mediaVolume, 0, 14, 100, 300));
    addTrack(sndStart);
  }
  else // Otherwise load normal game sounds
  {
    sndJump = new MPTrack("/RingoSki/jump.wav");
    sndJump->setVolume(map(mp.mediaVolume, 0, 14, 100, 300));
    addTrack(sndJump);

    sndSlide = new MPTrack("/RingoSki/speed.wav");
    sndSlide->setVolume(map(mp.mediaVolume, 0, 14, 100, 300));
    addTrack(sndSlide);

    sndCrash = new MPTrack("/RingoSki/crash.wav");
    sndCrash->setVolume(map(mp.mediaVolume, 0, 14, 100, 300));
    addTrack(sndCrash);

    sndGameOver = new MPTrack("/RingoSki/gameover.wav");
    sndGameOver->setVolume(map(mp.mediaVolume, 0, 14, 100, 300));
    addTrack(sndGameOver);
  }
}

reloadSounds() function:

void reloadSounds()
{
  closeSounds();
  initSounds();
}

closeSounds() function:

void closeSounds()
{
  for(int i = 0; i < 4; i++)
  {
    if(tracks[i] != NULL)
    {
      removeTrack(tracks[i]);
    }
  }
}

Another strange behavior I encountered with the sound FX is that sometimes they get randomly unloaded. If you try to play an unloaded MPTrack, your Ringo will crash. I deduced that this probably has something to do with what is happening in mp.update(), such as accessing the main menu, power save mode activating, an incoming call, etc. trying to use one of the 4 slots. I made a “safe” playSound() function to solve this problem:

void playSound(MPTrack *snd)
{
  // safety check (sometimes things go wrong with sounds loaded from SD card)
  if(snd == NULL || snd->getSize() <= 0)
  {
    reloadSounds();
  }

  snd->rewind();
  snd->play();
}

Oh and one last thing about sound FX: Ringo is very picky regarding what type of .wav file it will play. Keep them short in duration, otherwise they will playback very slowly. Also, it appears that the bitdepth must be 16 or less, higher bitdepths result in static.

Update from @robertCM regarding .wav properties: The ideal .wav is 16-bit , 44100 Hz, and mono for Ringo.

Saving a Simple High Score Value
If you want to save multiple high scores in JSON format to the SD card, SpaceRocks, Snake, and Invaderz have excellent examples of this.

However, if you simply want to store an integer (like in RingoSki), you can do so like this:

  int value = 1000; // example value of 1000 to save to SD 
  File file = SD.open(highScorePath, "w");
  file.write((byte*)&value, sizeof(int)); // write 4 bytes
  file.close();

Reading the value back is just as easy:

  int value = 0; // initialize value to something known in case something goes wrong
  File file = SD.open(highScorePath);
  file.read((byte*)&value, sizeof(int)); // read 4 bytes
  file.close();

Important: As I mentioned above, make sure you unload other objects (such as MPTracks) that are using the SD card before you read/write to it, you can reload them after your read/write operation completes.

Collision Detection
If you developed for the Gamebuino/MAKERbuino, you probably noticed that their library provides bitmap to bitmap collision detection for you.

When developing for the Ringo, it is up to you to roll your own collision detection. For RingoSki I decided on two collision boxes per Sprite, which is plenty to provide “good enough” collision detection while also giving a slight advantage to the player by making the bounding boxes a bit on the small side.

If you want to see the collision boxes in action in RingoSki, press ‘0’ on the numpad while playing to bring up the debugger.

ringoski-9 ringoski-10

----------------------------------------------------

Closing Remarks

Well, I think that is about all I have for now! If I think of anything else I’ll reply here. Enjoy, and please share your thoughts (and high scores :wink:).

1 Like

Well - I’m just amazed by this whole post!

Thank you very much you kind sir! :smiley:

I’ll definitely get on to playing as soon as possible.

Just the info about the sound - it depends on the type of the .wav file. You must set the exact frequency and bit depth in order for the track to work properly, and it has be mono as well. We’ve also ran into some of the issues with that but converting the track to the right parameters solved all of them. :slight_smile:

Cheers,
Robert

1 Like

Any time Robert! I really enjoy developing for Ringo, it was a fun project :slight_smile:

What have you guys found to be the ideal .wav parameters? From what I’ve experimented with, it appears that 16-bit bitdepth, 44100 Hz, and mono was the winning combo. I’m still not certain if duration factors into it, I tried some title music that was about 30 seconds long and it played, but slowly (however those files may have been stereo, I can’t remember :sweat_smile:).

Yes, it’s 16-bit 44100Hz, mono channel, and that should always work the same. Maybe if you used a converter that doesn’t do that properly you get some different results, but these are the parameters that work like a charm. :slight_smile:

Robert

1 Like

Hi DelPozzo,
My son has been a little disappointed with his Ringo compared to the Makerbuino because of the lack of additional games - and a mistaken belief he could just recompile an older Makerbuino game ‘as-is’ for Ringo.

It was his birthday yesterday and he discovered your game and immediately copied it onto his SD card and started playing non-stop. It really made his day to see the platform is active and what is possible.

Huge thanks for the game and explanation - he is already thinking about how it could be reversed and sprites changed to produce an ‘out run’ style car game.

Cheers,
Marcus

2 Likes

Yes, it’s 16-bit 44100Hz, mono channel, and that should always work the same. Maybe if you used a converter that doesn’t do that properly you get some different results, but these are the parameters that work like a charm

@robertCM - That is great to know! I updated the original post with this information.


@mbagsh55 – First off happy belated birthday to your son! The fact that this has inspired him to think about adapting it into another Ringo game has put a smile on my face and made my day as well :slight_smile: !

At the core of RingoSki is a basic 2D game paradigm that could definitely be adapted to make games of many different genres. An Out Run style game would be a fantastic candidate! I tried to comment my code somewhat thoroughly and use naming conventions and an overall flow that can be easily understood.

The methodology used in RingoSki is that everything is an entity, and entities can think. The player, penguins, yeti, trees, and logs are just entities in the global EntityList[]. The primary purpose of the main game loop is to simply make a call to each entity’s think function via thinkAll(), followed by drawing each entity’s current sprite frame with drawAll().

// Game Loop
void gameLoop()
{
  mp.update(); // call MAKERphone update()
  mp.display.fillScreen(TFT_WHITE); // fill background with white

  thinkAll(); // call each entity's think function
  updateCamera(); // center camera on player
  drawAll(); // draw each entity's current frame
  drawScore(); // draw score

  if(debugMode) // draw debug information
  {
    drawDebug();
  }
}

Much of the work to adapt RingoSki into a new game can be accomplished by doing the following:

  1. Add or remove variables to the Entity structure so they make sense for the game you want to create. The same goes for enums, defines, and other globals that are located near the top of the sketch.
// Entity structure
typedef struct ENTITY_S
{
  byte frame; // sprite frame that will be rendered in the next drawAll() iteration
  Sprite *sprite[MAXFRAMES]; // array of sprites for entity of size MAXFRAMES
  int16_t x; // x coordinate of entity
  int16_t y; // y coordinate of entity
  byte type; // entity type (PLAYER or OBSTACLE)
  byte xspeed; // x movement speed
  byte yspeed; // y movement speed
  byte flag[MAXFLAGS]; // array of flags for entity of size MAXFLAGS
  boolean used; // whether this particular entity in the EntityList[] is in use or not
  void(*think)(struct ENTITY_S *self); // think function that will be called in the next thinkAll() iteration
} Entity;
  1. Code the behavior of your entities by making their spawn function and corresponding think function(s). For RingoSki, the main flow of the game (including joystick/button input) is driven by the player’s think function on line 1142 in RingoSki.ino.

RingoSki Log Spawn Example:

Entity* spawnLog(int16_t x, int16_t y, byte type)
{
  Entity *self = spawnEntity();
  if(self == NULL) return NULL;

  self->think = logThink;
  self->x = x;
  self->y = y;
  self->type = OBSTACLE;
  self->frame = 0;

  switch(type) // type determines which sprite this log gets
  {
    case 1: self->sprite[0] = &spLog1;
      break;
    case 2: self->sprite[0] = &spLog2;
      break;
    case 3: self->sprite[0] = &spLog3;
      break;
    case 4: self->sprite[0] = &spLog4;
      break;
  }

  return self;
}

RingoSki Log Think Example:

void logThink(Entity *self)
{
  // Ignore player if jumping
  if(player->flag[JUMP]) return;

  // Collision with player
  if(!player->flag[INVUL] && collide(self, player))
  {
    for (uint8_t i = 0; i < 8; i++)
    {
      mp.leds[i] = CRGB(255,0,0); // flash red LEDs
    }

    playSound(sndCrash);
    player->flag[CRASH] = CRASHTIMER;
  }
}
  1. Create sprites and ensure that Sprite.h is updated with the raw bitmap data along with updating the variables in your sketch that reference those sprites. It is important to update the initSprites() function-- this is where all sprite-related attributes such as width, height, number of bounding boxes, and bounding box dimensions/offsets are initialized.

  2. Create sound fx (16-bit 44100Hz mono .wav files) and update MPTrack variable references accordingly.

I hope this is helpful, and if your son makes any progress on a new game please share the results with us!

1 Like

Thanks @delpozzo,
I did not expect you to so generously take time to explain the programming approach, but really appreciate this and know it will help both me and my son immensely.

As with many things in life, I feel getting started with game programming is probably the hardest step and your kind source code donation and explanation make this step much easier.

All the best.
Regards,
Marcus

2 Likes

Any time @mbagsh55, it’s no trouble at all! If you or your son have any questions feel free to ask, always happy to help :slight_smile: !

1 Like