Program Design

Graphics/Plotting on VGA

The main constraint with the project was the combination of limited RAM and an enjoyable playing experience. Any asset or level design needed to be coded in flash, but also needed to be written to the VGA screen with minimal RAM or computation. These asset and level designs needed to be detailed to replicate the original NES game, which meant each tile would be 16 x 16 blocks, and each level would contain 16 x 11 tiles. To make the game more visually appealing, each block would be 2 x 2 pixels on the VGA screen.

Example_tile
A. Pixel (single pixel on the VGA) B. Block (2 x 2 Pixels for a single color) C. Tile (16 x 16 blocks to be a design)

The framework of the VGA driver developed by V. Hunter Adams and Bruce Land was used as a starting point. The driver interacted with the VGA outputs through a PIO configured on the Pico to send data at the frequency required for the VGA screen, and a DMA was responsible for transferring the correct data to the screen. The data was encoded in an byte array of 320 x 240, where every 4 bits represented the color of a single pixel on the screen, with the index being the position on the screen. This effectively created an array where every 4 bits corresponded to the color of a pixel on the screen. The array was one dimensional which meant the index was represented by x_pos + 480 * y_pos. This meant it was easy to access contiguous pixel columns, but moving rows required a jump in the array.

Writing to the VGA screen one pixel at a time would be far too slow, and the delays and stutters of the background would be significantly more noticeable which meant multiple pixels needed to be changed at a time. This required usage of pointer arithmetic and memcpy() such that memory can be moved without having to be copied to RAM. The designs for each tile were stored in flash as hex values, doubled across each column but computationally duplicated across two rows to create a 2 x 2 block. Memory transfers were limited to 32 bits due to the bus of the Pico, but encoding the design as type long long allowed for the designer to more easily adjust the design of the tile. Each tile is a two dimensional long long array of size 2 x 16. Since a long long is 64 bits, it encoded for 16 pixels, but since a tile is 32 pixels across, each row needed to span across two indices. A pointer would traverse the array, copying memory appropriately to where they are supposed to appear on the screen. (For more information, see Visual Design).

To write the main character, enemies, and entities that sit “on top” of the background, there needs to be a consideration for “transparent blocks”. Since the designs need to be hard coded, there is no way to hardcode transparency since every hex value corresponds to a color, and even if a value were to be assigned, the computation of looking for that value defeats the purpose of using memcpy() for speed. The alternative was to use a “mask” for every transparent design that specified which blocks were “transparent”. This mask would be compared against the existing tile through a bit-wise OR, and inverted to compare against the design to determine which bits needed to be from the background and which from the design, and memcpy() the modified design onto the VGA screen.

  
      void drawTop(x, y, *des, *mask)
      int start_pos = 640 * y + x
      long long copied_original
      for (int i = 0; i < 16; i++)         // Repeat for every row
        // to draw the two sides of the row
        for (int r = 0; r < 2; r++)        // Repeat to complete row

          // Duplicate mask into hex value equivalet
          long long full_mask = *mask;
          full_mask = (full_mask | (full_mask << 28))
          full_mask = (full_mask | (full_mask << 14)) & 0x3000300030003;
          full_mask = (full_mask | (full_mask << 7)) & 0x101010101010101;
          full_mask = (full_mask << 8) - full_mask;

          // Overlay mask over what's currently on the screen
          memcpy(&copied_original, &vga_data_array[(start_pos + r * 16 + 640 * 2 * i) >> 1], 8);
          long long masked_screen = full_mask & copied_original;
          // Overlay design onto original screen
          long long new_screen = masked_screen | (~full_mask & *des);

          // Print to the screen
          memcpy(&vga_data_array[(start_pos + r * 16 + 640 * 2 * i) >> 1], &new_screen, 8);
          memcpy(&vga_data_array[(start_pos + r * 16 + (640 * 2 * i) + 640) >> 1], &new_screen, 8);
          des += 1;
          mask += 1;
  
  
Example_tile
Character over multiple tiles

To store the tiles and use them in flash, the pointer to the start of the design or mask array is in an array. This array of pointers stores the design and mask for the character in any state it might be in. For the main character, this array stores the 2 frames for walking in any direction and the attacking frames in any direction. Enums are used to more easily index into this array. This system was designed like this because for characters such as enemies, there will be multiple enemy types that each have a direction design. This means there will be a pointer to the aforementioned array of pointers, to specify which enemy is being drawn. This creates an array of pointers, whose pointers point to a specific set (array of pointers) of the designs. These are indexed and read through pointer arithmetic, taking advantage of the fact that arrays store memory contiguously. This was implemented to be as conservative with flash memory and RAM as possible, as well as having the flexibility to easily change, add, or delete designs.

Visual diversity was accomplished through drawing mono-colored tiles. This just involved passing in the color and duplicating it such that the length spans 32 pixels on the screen, and copying it into the VGA array

    
      void drawMono(x, y, color)
        int start_pos = 640 * y + x;
        long long long_mask = 0;

        // Duplicate color
        for (int i = 0; i < 16; i++)
          long_mask |= color << (4 * i);

        // Drawing multiple rows
        for (int j = 0; j < 32; j += 2)
          // Draws a full row
          for (int i = 0; i < 2; i++)
            memcpy(&vga_data_array[(start_pos + 16 * i + 640 * j) >> 1], &long_mask, 8);
            memcpy(&vga_data_array[(start_pos + 16 * i + 640 * (j + 1)) >> 1], &long_mask, 8);
    
  

Additional functions were added to draw the hearts, gems, weapon slot, and weapon to create the interface. To write strings onto the screen, a function from the framework of the original VGA driver was used. This would be used to write “ – LIFE – “, the number of gems, and text that needed to be conveyed to the player. Drawing this interface only happens when someting changes to avoid unnecessary computation.

Game Code Structure

The game engine is designed to emulate playing the original game. Since the game is played through a serial monitor, an entire core was used to read the input of the player through protothread_serial. The other core was used for the game logic and graphics in protothread_game and protothread_graphics. An additional thread handles periodic events in protothread_periodic. The game was designed such that the serial thread set flags for the intended actions of the player, while the other core was responsible for any changes to the state of the game.

Serial input takes such priority because player experience was valued most. A game that is unresponsive or feels inconsistent can ruin the experience regardless of how fun the game is. This design decision was informed by making sure the player had agency over their character and could smoothly handle the character within the constraints of the actual game.

block diagram

There are timing constraints on how often/long something happens, which is handled through protothread_periodic. There are specific time cooldown variables that mark the threshold for how much time needs to have passed in order for a conditional to allow the event. This handles cooldowns for attacking, when to move after attacking, updating enemy movements, how fast the game processes serial inputs, and how long to draw tiles like the death cloud for when enemies die.

The graphics thread was responsible for drawing the background, what was “on top” of the background, the interface, and anything else that needed to appear on the screen. It does not handle computation, and is signaled to redraw the screen every time the protothread_game has finished running. Whenever a character on the screen moves/changes, it does not handle erasing the “shadow” left behind, which means it needs to redraw the background and draw the character on top to give the illusion of movement. Redrawing the screen too often caused flashes of the characters, which is why redrawing the screen is triggered on events. These events can be either a periodic timer or a player input.

Flashing1 Flashing2
Screen flashing

Any item above the background is either an entity or a character. The location is stored as a set of global positions of tiles and local position of blocks. Each character has an index value enum that states which character model is being drawn (for which enemies/if the main character has multiple designs), and an index value enum that states what the state of the character is (for which direction they are walking or attacking). Each character also has a size to determine the hitbox detection, the number of hearts they have, and the block move speed of the character. An additional field for characters is which tiles they can/cannot walk over, and for entities stores miscellaneous information.

The game state keeps track of which level the game is currently on, which the demo level does not implement but is there for completion, the current screen of the level, the respawn position for when the character dies on a particular screen, as well as flags for the protothread_serial to interact with protothread_game and protothread_graphics. Every enemy, entity, and level transition box is kept in its own array.

Game Logic

The major implementations of game logic came in the form of obeying terrain, hitbox/hurtbox detection, and interacting with the environment. There are additional notes on initialization, single sequence events (SSE), game-to-player dialogue, and enemy pathfinding.

Terrain Detection

Since there are no set boundaries on which tile designs are walkable and which are not, the character’s movement restrictions cannot be generated from the screen design. Therefore, an additional mask is required to determine which tiles are traversable. The terrain collision is stored through a field that contains information on whether the character can move into a new global tile in each of the cardinal directions. This value is generated by checking the terrain mask with the character’s current position, as well as collisions with entities such as signs, boulders, etc. Since the character’s local position is stored as the top left of the character, there are some corner cases where the new tile location should only be registered as the local position reaches the end of a tile. There is additional logic for enforcing when the character’s physical position spans across two global positions. Entities are bound by the same wall logic, which handles the case of boulders being pushed into other boulders/signs.

Wall Corner Case
Black square is where the global position is, character crosses into another global square

Moving the character involves a function move_char() to check against this terrain detection to see if the movement is valid, and shuffling between the two walking frames to create the illusion of walking. If it is not a valid space the function will return, otherwise it will move the character and adjust the global and local position as needed. Entities are processed the same, but moved only when interacted with.

Hitbox and hurtbox detection

Hitboxes and hurtboxes are used to determine when a character gets hit. The hitbox is the “attack”, and will damage a character when it intersects with their hurtbox. The 2D collision detection algorithm is the axis-aligned bounding box, which works particularly well because it is an algorithm used to detect the collision of two rectangles.

    
      if (
        rect1.x < rect2.x + rect2.w && rect1.x + rect1.w > rect2.x &&
        rect1.y < rect2.y + rect2.h && rect1.h + rect1.y > rect2.y)
    
  

Every time protothread_game gets updated, it will do a hitbox-hurtbox detection in mc_hitbox_check() for all the enemies on the screen, which will return a nonnegative number to indicate the direction the main character gets hit, or 0 otherwise. To allow the player to react accordingly, the main character gets pushed back a certain number of blocks in the opposite direction they get hit in. Every time the character attacks, a flag will cause a call on attack_hitbox_check(). It uses the location and size of the sword to check against every enemy, and if any of them are alive and the rectangles intersect, it will return the index of the enemy in enemies[]. If not, it will return -1 to indicate nothing happened. protothread_game will decrease the number of hearts of the enemy that corresponds to that index, and determine if the enemy dies or needs to be knocked back from the character. Since enemies do not have a weapon, their hitbox and hurtbox will depend on the size of their character. The main character’s entire model is the hurtbox, and the hitbox is a disjointed sword that adjusts its hurtbox size depending on which direction the player attacks.

Hitbox
Red Rectangles for Hitboxes/Hurtboxes

Interacting With the Environment

There are two ways to interact with the environment: running over passive objects or interacting with active objects. Passive objects are items such as gems and hearts, which only require the player to touch the item to pick it up. Active objects are boulders, signs, and villagers that the player needs to actively interact with to change the state of the game.

Similar to how the hitbox-hurtbox detection will be run every time protothread_game gets updated, the thread will check for passive objects and screen transition blocks continuously. The function check_environment() will check every entity to see if it is a passive object, and use the axis aligned bounding box algorithm against that entity to see if collision happens. It will return the index of the entity in entities[] that the player makes contact with, and -1 if nothing happens. The function check_level() will see if the character collides with a box that causes the screen to transition, and will return the direction the main character moves on the screen, or -1 if nothing happens. The specific event will be handled by protothread_game. If the item is meant to disappear, the thread will set it to nothing.

gem1 gem2
Gem Disappearing

When the player interacts, a flag will cause the function check_front() to see if there is anything interactable in front of the character. If an entity exists right in front of the character and the character is facing the correct direction, it will return the index in entities[], or -1 if nothing happens. protothread_game will then determine what the entity is, and perform the appropriate action.

interact1 interact2
Interacting with a Boulder

Additional Notes

Two types of initialization happen. One initialization, init_game() and init_main_char() happens when the game boots up, where all the time values, game state variables, and main character get assigned the proper values. The second initialization, init_screen, happens right after the first initialization, and also upon entering a new screen. This initialization takes all the enemies, entities, signs, etc (stored as interactables), assigned to that certain screen in flash, and places each interactable in the correct array. These interactables are stored in flash such that expandability of the game is maintained, which means all the information to identify and initialize the object is efficiently stored in 4 hex values. The first hex value is to identify what is being placed, the second is to provide additional information, and the last two are the x and y global tile positions respectively. The arrays are first emptied out in order to prevent memory lapses or leftovers from the previous screens, and then each of the interactables are read through in order to determine where they belong. There are additional cases for single sequence events and signs.

Single sequence events (SSE) are events that will change their state “permanently” after the player has interacted with them. Since interactables are loaded from flash, there is no way to adjust the flash for that individual interactable to make sure they are adjusted appropriately the next time. This is handled in the init_screen() function, where corner cases for specific objects with specific pre and post SSE are hard coded. The SSE for that particular screen is checked, and the objects are initialized appropriately.

sse1 sse2 sse3
Entering Screen, Taking Sword, Re-Entering Screen

Entities such as signs and the villager have dialogue in the form of words that need to be conveyed to the player. This text appears at the top of the screen where the interface is, and should change depending on which sign is being read. This is stored in flash as a different interactable but in the same entities array, where the first value is the indicator for the sign, the second value is an index into a string coded in flash. Whenever the player interacts with a sign, the index for that specific sign is used to find the correct string to write. Upon pressing the interact button again, the text box closes. The villager in the demo is also used as an SSE, where before giving him the gems, it will read a certain text, and after the gems are given the villager will say different things.

sign1 sign2
Different Signs
guy2 guy1
Villager Text Single Sequence Event

Enemy pathfinding is partly random, but generally moves towards the player. The distance between the enemy and the main character determines how likely it is that the enemy will move in that direction towards the player whenever the enemy does move. This means that if the enemy is close on the y-axis but far on the x-axis, it is much more likely for the enemy to move towards the player by moving closer in the x-axis direction.