A 1v1 Brawl Stars knock-off, but with tanks. And played on an FPGA board.
Brawl Tanks is a 2 player arena shooter game where players control their own tank to take down their opponent’s tank. We have made use of 2 Basys3 boards that communicate through UART communication protocols, with OLED displays connected to the JC ports and a wired mouse connected to the USB port of the boards. Both boards use ports JA1, JA2, JA3, JA4, JA7 and JA8 for UART communication. JA1 is connected to JA2 of the other board, JA3 to JA4, and JA7 to JA8, and vice-versa.
On the initial screen, the player is prompted to press btnC when ready. This brings the player to either a waiting screen or a countdown screen, depending on whether the opposing player has also pressed btnC. The game starts after the countdown on the screen ends.
When the game starts, players can traverse their tanks through a 150x120 pixel map with obstacles using btnU, btnD, btnL and btnR, that moves the tank up, down, left and right respectively relative to the direction it is facing. If the user holds the right mouse button and presses either btnL or btnR, the tank will rotate clockwise or anticlockwise instead. Players can fire a bullet in the direction it faces by pressing the left mouse button. Players will have 5 bullets in their magazine that they can fire before needing to wait approximately 1.8 seconds to reload before they can fire again. The number of bullets that players have in their magazine is indicated by the 7-segment displays on the right most anode
From each respective player’s perspective, their own tank and bullet is coloured blue and green, while the opposing tank is purple and yellow. Each player's actions (movement and shooting) will be updated constantly to the other player’s board in real time, and the positional changes of the opposing player’s tank on the player’s camera is rendered accordingly.
Both players start off with 7 health points (HP), and lose 1 HP when hit with the opposing player’s bullet. The game ends when one player’s HP reaches 0. Each player’s HP is displayed on LED[15:9] of the board, with LED[15:9] lit indicating 7 HP, LED[14:9] lit indicating 6 HP, etc. The first person to lose all HP loses, while the other player wins. This is indicated by the respective player’s ending screens on the OLED display and the 7-segment displays that read either “YAY” or “LOSE”.
After the game ends, players can choose to play again by repeating the initial process of pressing btnC and waiting for the other player to do the same.
Here is a low quality video of the PVP action (sorry we are not paid enough):
www.youtube.com/watch?v=juCPU7xvqJU&ab_channel=ShuiHonSiew
UART is implemented for data communication between the two separate boards. The UART system implemented is able to transmit and receive a data stream of at most 18 bits at a time at a baud rate of 500k. The maximum data stream was chosen to be 18 because it is the largest size of data that is required to be transmitted at a time. Before the game starts, the player’s ready state will be communicated between boards. During the game, the following data will be transmitted between boards - player's centre positions and direction on the map, player’s bullet states, and player’s bullet hitting the opposing player. Each type of data is transmitted through its own UART data line, making use of 3 different UART connections. This is to prevent complicating the data packets with different types of data as each type of data will only be transmitted at certain circumstances. For each 18 bit data stream, Player’s centre position contains 3 bit direction value, 8 bit x coordinate and 8 bit y coordinate of the tank’s centre. This is only transmitted when a player tank has moved. Player’s bullet state contains 5 bit fired states (whether a bullet has been fired) and 5 bit collided states (whether a bullet has collided with a tank or obstacles). This is transmitted when a player fires a bullet or a fired bullet has collided with an object. Player’s bullet hit contains a 16 bit value that is agreed upon by the board to indicate an enemy hit, which is only transmitted when a hit with an opponent has occurred. The other board that receives this data will process it accordingly. This enables real-time communication and updates in each board so that it correctly reflects the game state and positional data of the tanks and bullets of both players accordingly in the OLED displays.
For game logic, the game will be in either one of these 4 different states, initialise, start, end, and new_game. In initialize, the program will wait for both players to be ready before transitioning to start. In start, the game will run as mentioned earlier and transitions to end when one of the player’s HP reaches 0, end transitions to new_game when the user presses btnC again where the program will again wait for the other player to be ready. From new_game, approximately 3 seconds after both players are ready, the game state transitions to start. Game logic also handles all the relative information and data received from the UART. Opposing player’s tank positional data received is converted to the relative position of the tank. Hit data received also adjusts the user’s HP accordingly. If it receives data that the opposing player has fired a bullet, it will also produce and fire a bullet from the opposing tank’s position on the player’s screen accordingly. I have also reproduced the images on the OLED display according to the game state.
During the game, collisions will be constantly checked between moving objects and any object in the map. Collision detection is in place to prevent any object from passing through walls or objects in the map which makes use of XY coordinates relative to the map. Since we only need to consider collisions for moving objects, we constantly check whether it is possible for objects to move left, right, up or down relative to the map. This involves checking whether the centre coordinate of the object is in the vicinity of colliding with both a static object (wall) and a moving object (opposing tank and bullets) by comparing the coordinate with the coordinates of static objects and the centre coordinates of moving objects. When bullets collide with any object, the bullet will be “destroyed”. We will however ignore collisions between bullets as it is deemed irrelevant to the purpose of the game. We will also not perform collision checks between player bullets and their own player tank models as it is not possible for them to collide since bullets travel only in 1 direction approximately twice the speed of tanks. Bullet collisions with opposing tank models are labelled a special type of collision, hit. A hit will indicate that a tank has been damaged. We will only detect hits between player bullets and the opposing tank, as opponent hits will be received from the UART communication.
For the game’s visuals, the game camera (player view) uses logic to render positional information onto the OLED display. The camera module receives user/enemy’s tank and bullet XY-coordinates and rotation values from the movement and shooting module and outputs pixel data for the OLED display. The game map is 213x211 pixels big and is formed by walls and a playable area in the centre which contains obstacles. The users’ manoeuvrable area ranges 120x150 pixels (x-coordinates 48-167 and y-coordinates 48-197). Outside the manoeuvrable area are the map borders (x-coordinates 0-47, 168-214 and y-coordinates 0-47, 198-212). For ease of conversion, we used a 1:1 scale to convert object coordinates to OLED pixel coordinates for all OLED-related calculations. Map border is formed by 4 grey blocks (x < 48 , x > 167, y < 48, y > 197), obstacles are formed by brown blocks and the remaining playable area is black. Player/enemy tanks are a combination of blue/purple/red/white and player bullets are green blocks while enemy bullets are yellow blocks.
Positional data is stored in two 9-bit registers for XY-coordinates for each renderable object - player tank, enemy tank, map borders and map obstacles. Player and enemy tank rotation is additionally stored in 3-bit registers. To determine where to render the objects on the screen, we used simple vector maths to calculate the relative object position with respect to the camera’s centre, e.g. relative_x = obj_x - camera_x. The camera centre is fixed 16 units above the player centre, where 9-bit registers camera_x = player_x and camera_y = player_y + 16. Camera coordinates constantly update with player XY-coordinates, effectively achieving 2D camera tracking that follows the player.
We calculated our render region as a 96x64 rectangle that follows the camera coordinates. Any objects within the render region are generated in the OLED display. We use objects’ relative coordinates calculated earlier to determine which objects fall under this rectangle. If the relative XY-coordinates of any part of the object is between the OLED’s pixel range (0-95 and 0-63 respectively), it is within the render region. After calculating what object each pixel represents, we pass the respective colour data to each pixel on the OLED display. If any pixel does not contain an object, it is assigned black to represent the playable area.
For player/enemy tanks, we emulated sprite rendering due to the complex design of the tank sprites. We have 8 sprites representing 8 possible orientations (Up, left, down, right, diagonals) for both tanks. Each sprite is represented by 3 wires and each wire stores pixel coordinates for its specific colour. Each wire stores conditionals to calculate the relative pixel coordinates w.r.t to enemy/user tanks’ coordinates. To render the player/enemy tank, the correct sprite is chosen by checking the player/enemy rotation. The 3 wires representing the chosen sprite then uses player/enemy tank XY-coordinates to calculate the actual pixels on OLED to assign each colour’s pixel data, hence generating enemy and player sprites on the OLED display.
The simplistic approach to generate objects in the map is to check if an objects’ edge lies within the render area and generate the object if true. However, this implementation fails where an object lies partially within the render region but does not generate at all since its edge falls outside the render region. To solve this issue, we emulate 2D block rendering by assigning variable edges to objects that update based on camera coordinates e.g. pill_x_l = cam_min_x > 95 && cam_min_x < 121) ? 0 : 95 - cam_min_x. If an obstacle is fully within the render region, it will render as per normal. If the left side of the object is partially outside the render region, its left variable edge is calculated subtracting the coordinates of its left edge and camera’s left edge. If this variable edge is within the pixel range to render, the object renders from its original right edge to its new left edge. For obstacles in the playable area, variable edges are calculated for their top, bottom, left and right edges. For borders, variable edge is calculated only for the inner edge (touching playable area).
The movement module detects activation of push buttons and increments/changes the centre x, y pixel of the tank relative to its current orientation/direction that it is facing. The user can move the tank in 8 different directions. By pressing the button up, down, left and right, the tank would move in the north, south, west and east respectively. To move in the other 4 directions, namely in the north-east, north-west, south-east and south-west directions, the user would need to change the direction of the tank, which will be explained more under “tank rotation”. Then, the centre x, y pixel is passed to the tank display module, where the movement of the tank will be mapped accordingly. Things to note: when the tank is facing different directions, up, down, left and right,, all movements and changes of the x, y centre coordinates have to be mapped out relative to each direction the tank is facing.
A tank rotation module was introduced to track the tank direction state. It makes use of a 3 bit direction variable to hold 8 different directions. The directions 1-8 represents the 8 different directions clockwise from north to northwest. In order to change the direction, the player has to hold down the right click of the mouse and press btnR or btnL. Changing the direction would result in the tank rotating to face the particular direction. Things to note: The movement of the tank does not need debouncing as the tank should move forward continuously in different directions when its respective button is held down. However, rotation would require debouncing to ensure the tank rotates by 45 degrees every push. Thus, debouncing had to be added for tank rotation.
A bullet module was created to track the direction of a bullet and if the bullet has been fired. This module 5 times to represent the 5 bullets the tank can shoot. Additionally, a variable was used to track if 5 bullets have been fired to initiate a reloading phase that takes 2 seconds before the 5 bullets are replenished. Bullets remaining are also being displayed on the segment screen. For bullets that remain to be fired, there is approximately a 200ms delay before bullets can be shot in succession.