As previously mentioned, our project’s inspiration was drawn from the original “Tennis for Two” video game. We were able to find videos on YouTube of the original gameplay and it was helpful for us to see the pace the ball moved at and some of the underlying physics in the game. For example when we were setting parameters such as gravity and the velocity of the ball, we tried to match a similar ball flight as that to the original. We also drew inspiration from the original controllers they designed for the game which consisted of a push button and a knob. When designing and building the controllers, we aimed for creating a very similar model.
x = x0 + t*v0*cos(theta)
y = y0 + t*v0*sin(theta) + 0.5*g*t^2
x0 and y0 represent the previous position of the ball, t represents the amount of time elapsed since the beginning of the trajectory, v0 is the initial velocity, g is gravity, and theta is the angle at the beginning of the trajectory. g and v0 are constants that we determined based on trial and error of gameplay and the speed of the game that we thought was best. The launch angle, theta , was determined by user input, specifically the knob on each player’s controller. Lastly we kept time t as a counter variable. In our case, we used an interrupt service routine (ISR) to configure a signal from the RP2040 that we sent to a DAC that was then sent to the oscilloscope. This will be discussed further when we talk more about the DAC, but time t was the number of interrupts since the beginning of the trajectory.
We implemented bouncing the ball off of surfaces by calculating the new launch angle theta when the ball hits the ground or net. When the ball hits a surface, the angle just before collision is found by measuring the difference between the x and y coordinates of the ball as it hits the surface and just before the collision, and using these to calculate the incident angle using arctangent. The reflected angle is the same as the incident angle, but reflected about the axis normal to the surface. This is used as the new launch angle for the trajectory equations above. We also implemented some kinetic energy loss on bounces by simply multiplying the initial velocity, v0, by a positive coefficient less than 1. For the net, we made this coefficient 0.5, since a real tennis ball loses a lot of speed when it hits the net. For bouncing off the ground, we found that the first bounce looked best with no loss of speed, which makes sense since real tennis balls lose very little speed when hitting the ground. However, if the ball bounces multiple times–after hitting the net, for example–some damping was necessary for the bouncing to look realistic. So, we set our damping coefficient to 0.9 when bouncing off the ground, but only if the ball has already bounced once, off the net or ground.
1. A player hits the ball out of bounds without hitting the other player’s side
2. A player lets the ball bounce twice on their side
3. A player lets the ball bounce once on their side then go out of bounds
4. A player hits the ball into the ground on their own side or it hits the net then bounces onto the ground on their own side
Similarly, there are multiple ways that the ball trajectory can change. They are below:
1. The ball hits the ground
2. A player hits the ball
3. The ball hits the net
Because all of these sudden changes to the game depend on a player's action or where the ball is, we use a lot of conditional statements in this thread to check button presses and ball position. At the end of the thread, we update our score if either player has won a point. This is simply done by setting GPIO pins high to light up an LED on our score tracking device. Our heavily commented code implementation is below in Appendix E.
One more feature we added to the game that hasn’t been mentioned so far is our “loading/start” screen. This screen is displayed after a game is played and when first booting up the RP2040. On this screen, there is a ball traveling in a perfect circle resembling the loading symbol. The game is waiting for either player to hit their respective button. Whichever player hits their button will be the server for the upcoming game. We added this screen in because we wanted the option for either player to be the server and we believed that having a different screen would best indicate this. Specifically we chose to make the loading circle our screen because at the beginning of this project we experimented with drawing trajectories with the direct digital synthesis algorithm as in lab 1. As a result, some of the code we used is repurposed from lab 1 such as building the sin table in the main function. Although the game still looked pretty cool and arcade-like when we first started off using direct digital synthesis to draw circles for trajectories, it looked much more realistic after we implemented the parabolic trajectories instead.
Our controllers for each player consisted of a push button and knob. The push button was responsible for triggering the action of hitting the ball. The knob was used to allow the player to hit the ball at different angles. Images of both the controllers are shown below in Figure 2 and Figure 3. There are two connections on both of the push buttons. One connection is to the 3.3V signal produced by the RP2040 on the 3.3V pin. The other connection is to a GPIO pin on the RP2040 that we set up as an input pin. Essentially, when the push button is pressed, a short occurs which the input pin can now detect a signal. When the button is not pressed, the input pin does not detect a signal. These buttons were particularly nice because they did not require debouncing. They also had a tactile clicking sound which was aesthetically pleasing and an ode to the original game. Underneath the knob on the controllers is a 10k potentiometer. There are three connections to the potentiometer. The leftmost connection is the 3.3V signal from the 3.3V pin on the RP2040. The rightmost connection is to ground. The middle connection is to one of the ADC pins on the RP2040. This setup is a voltage divider where the output voltage changes as the knob on the potentiometer is turned. The built–in ADC on the RP2040 has a mux which we utilized to switch between the output voltage from the two controller’s potentiometers.
The last component of our hardware setup was our scoring tracker device which is displayed in Figure 4 below. This device consisted 4 green LEDs, 4 red LEDs, and 8 110Ω resistors. We set up 8 output GPIO pins on the RP2040 to connect to each of the resistors and LEDs in series. Then each of the negative LED terminals is connected to ground. The resistor value we used was decided based on testing multiple different valued resistors. We found with the 110Ω resistor that the current supplied to the LEDs was really bright for both colored LEDs so we went with that value.
We also took some additional footage to show off more of our game. The footage is below.
As a whole, we were really pleased with the usability of our game. Our gameplay was extremely responsive to the player's actions. Also, we believe we found the perfect balance of having enough time to hit the ball without making the game too easy. If the ball moves too slow, then the game can go on for a very long time. We wanted the player's reflexes and reaction time to be tested in our game and we believe that they are.
Overall, our results met our expectations. Having never seen a game on an oscilloscope besides in YouTube videos, we were not too sure what to expect along the way. For example, we found that running our game on different types of oscilloscopes affected the visuals. The oscilloscope in our final demonstration video is the oscilloscope that we had the best results with. If we had more time, it would be interesting to continue experimenting with analog oscilloscopes to bring more of the 1960s vibe to our game. We tried with several different analog oscilloscopes, but some of the analog oscilloscopes had issues due to old age. Then on an analog oscilloscope that we found to be working, our game experienced a lot of perceived lag. This could likely be due to the fact that as we designed our game it was tailored to the digital oscilloscope in our lab. Another interesting follow on would be running this game on a 4 channel oscilloscope. Only having access to 2 channel oscilloscopes, we had to switch between drawing the ball and drawing the net/ground. We believe this created some noise and we were able to see a faint trail between the ball and the net/ground at times. For this reason, we think it would be interesting to see the performance differences on a 4 channel oscilloscope instead. This would require another DAC, so 4 channels could be written to at a time. This should also allow the net and ground to be drawn at a much higher frequency which should decrease the noise around these objects.
We want to note too that we did start our project from the file we used in lab 1. Our code and file looks much different than lab 1, but some of the code regarding direct digital synthesis and setting up the DAC is from the lab 1 beep_beep.c file.
/**
* Jack Strope and Gabe Taylor
An implementation of Tennis for Two to run on an RP2040 microcontroller
and displayed on a two-channel oscilloscope.
*/
// Include necessary libraries
#include <stdio.h>
#include <math.h>
#include <string.h>
#include "pico/stdlib.h"
#include "pico/multicore.h"
#include "hardware/spi.h"
#include "hardware/sync.h"
#include "hardware/adc.h"
// Include protothreads
#include "pt_cornell_rp2040_v1.h"
// Include libraries for keypad
#include <stdlib.h>
#include "hardware/pio.h"
#include "hardware/dma.h"
// Player buttons and knobs
#define B1 21
#define B2 22
#define KNOB_1 26
#define KNOB_2 27
// Scoring LEDs
#define GREEN1 16
#define GREEN2 17
#define GREEN3 18
#define GREEN4 19
#define RED1 13
#define RED2 12
#define RED3 11
#define RED4 10
// Macros for fixed-point arithmetic (faster than floating point)
typedef signed int fix15 ;
#define multfix15(a,b) ((fix15)((((signed long long)(a))*((signed long long)(b)))>>15))
#define float2fix15(a) ((fix15)((a)*32768.0))
#define fix2float15(a) ((float)(a)/32768.0)
#define absfix15(a) abs(a)
#define int2fix15(a) ((fix15)(a << 15))
#define fix2int15(a) ((int)(a >> 15))
#define char2fix15(a) (fix15)(((fix15)(a)) << 15)
#define divfix(a,b) (fix15)( (((signed long long)(a)) << 15) / (b))
//Direct Digital Synthesis (DDS) parameters
#define two32 4294967296.0 // 2^32 (a constant)
#define Fs 40000 // sample rate
// the DDS units - core 0
// Phase accumulator and phase increment. Increment sets output frequency.
volatile unsigned int phase_accum_main_0;
volatile unsigned int phase_incr_main_0 = (400.0*two32)/Fs ;
// DDS sine table (populated in main())
#define sine_table_size 256
fix15 sin_table[sine_table_size] ;
// Values output to DAC
int DAC_output_0 ;
int DAC_output_1 ;
// Amplitude for radius of circle in start screen
fix15 radius = float2fix15(0.5) ;
// SPI data
uint16_t DAC_data_x ; // output value
uint16_t DAC_data_y ; // output value
// DAC parameters (see the DAC datasheet)
// A-channel, 1x, active
#define DAC_config_chan_A 0b0001000000000000
// B-channel, 1x, active
#define DAC_config_chan_B 0b1001000000000000
//SPI configurations (note these represent GPIO number, NOT pin number)
#define PIN_MISO 4
#define PIN_CS 5
#define PIN_SCK 6
#define PIN_MOSI 7
#define LDAC 8
#define LED 25
#define SPI_PORT spi0
#define ISR_time 16
volatile int corenum_0 ;
volatile unsigned int frequency = 0;
volatile unsigned int count_0 = 0 ;
volatile int bounce = 0;
volatile bool button1_state = false;
volatile bool button2_state = false;
volatile int angle;
volatile int x;
volatile int x_0 = 0;
volatile int y = 1000;
volatile int y_0 = 1000;
volatile int v0 = 0;
volatile double g = 0;
volatile bool net = false;
volatile bool player = false; // false == player 1; true == player 2
const float conversion_factor = 1.0f / (1 << 12);
volatile uint16_t result;
volatile bool change0 = true;
volatile bool change1 = true;
volatile int score_1;
volatile int score_2;
volatile bool p1_out;
volatile bool p1_once_out;
volatile bool p1_own_side;
volatile bool p1_bounce_twice;
volatile bool winner;
volatile bool p2_out;
volatile bool p2_once_out;
volatile bool p2_own_side;
volatile bool p2_bounce_twice;
volatile bool stop;
volatile bool gameOver = true;
volatile int last_y;
volatile int last_x;
volatile bool server;
volatile bool changeFromStart;
volatile int gameStartX;
volatile int before_ground_angle = 0;
volatile int bounceCount = 0;
static struct pt_sem game_semaphore ;
// This timer ISR is called on core 0
bool repeating_timer_callback_core_0(struct repeating_timer *t) {
// Mask with DAC control bits
DAC_data_y = (DAC_config_chan_B | (y & 0xffff)) ;
DAC_data_x = (DAC_config_chan_A | (x & 0xffff)) ;
// SPI write (no spinlock b/c of SPI buffer)
spi_write16_blocking(SPI_PORT, &(DAC_data_x), 1) ;
spi_write16_blocking(SPI_PORT, &(DAC_data_y), 1) ;
// retrieve core number of execution
corenum_0 = get_core_num() ;
PT_SEM_SIGNAL(pt, &game_semaphore);
return true;
}
static PT_THREAD (game_logic(struct pt *pt)){
PT_BEGIN(pt) ;
while(1){
PT_SEM_WAIT(pt, &game_semaphore);
if (gameOver){ // Start screen; draw circle animation
frequency = 150;
phase_incr_main_0 = frequency * two32 / Fs ;
phase_accum_main_0 += phase_incr_main_0 ;
if (winner){
x = fix2int15(multfix15(radius, sin_table[((int)two32/2 + phase_accum_main_0)>>24])) + 2047 ;
y = fix2int15(multfix15(radius, sin_table[phase_accum_main_0>>24])) + 2047 ;
}
else{
x = fix2int15(multfix15(radius, sin_table[(-(int)two32/2 + phase_accum_main_0)>>24])) + 2047 ;
y = fix2int15(multfix15(radius, sin_table[phase_accum_main_0>>24])) + 2047 ;
}
// Check which player hits the button first and set the ball up for their serve
if (gpio_get(B1)){
gameOver = false;
score_1 = 0;
score_2 = 0;
gpio_put(RED1, 0);
gpio_put(RED2, 0);
gpio_put(RED3, 0);
gpio_put(RED4, 0);
gpio_put(GREEN1, 0);
gpio_put(GREEN2, 0);
gpio_put(GREEN3, 0);
gpio_put(GREEN4, 0);
server = false;
player = false;
x_0 = 50;
x = 50;
gameStartX = 50;
}
else if (gpio_get(B2)){
gameOver = false;
score_1 = 0;
score_2 = 0;
gpio_put(RED1, 0);
gpio_put(RED2, 0);
gpio_put(RED3, 0);
gpio_put(RED4, 0);
gpio_put(GREEN1, 0);
gpio_put(GREEN2, 0);
gpio_put(GREEN3, 0);
gpio_put(GREEN4, 0);
player = true;
server = true;
x_0 = 4040;
x = 4040;
gameStartX = 4040;
}
}
else if (!gameOver){
if (!changeFromStart){
if (!gpio_get(B2) && !gpio_get(B1)){
changeFromStart = true;
}
}
else{
if (!button1_state && gpio_get(B1) && !player && y < 1570 && x < 2046 && change0) {
button1_state = true;
change0 = false;
bounceCount = 0;
} else {
button1_state = false;
}
if (!gpio_get(B1)){
change0 = true;
}
if (!button2_state && gpio_get(B2) && player && y < 1570 && x > 2046 && change1) {
button2_state = true;
change1 = false;
bounceCount = 0;
} else {
button2_state = false;
}
if (!gpio_get(B2)){
change1 = true;
}
if ((button1_state || button2_state)) { // button press
if (button2_state && player) { // player 2 presses and it is player 2's turn
player = false;
adc_select_input(1);
result = adc_read();
angle = 135 + result*conversion_factor*90;
}
else if (button1_state && !player) { // player 1 presses and it is player 1's turn
player = true;
adc_select_input(0);
result = adc_read();
angle = result*conversion_factor*90 + 315;
}
v0 = 37;
x_0 = x;
y_0 = y;
g = -0.6;
count_0 = 0;
}
}
x = x_0 + v0*count_0*cos(angle*M_PI/180);
y = y_0 + v0*count_0*sin(angle*M_PI/180) + 0.5*g*count_0*count_0;
// If the ball hits the ground
if (y < 50) {
if (x - last_x > 0){
before_ground_angle = 180 + atan2(x - last_x, y - last_y)*180/M_PI;
angle = before_ground_angle - 270;
}
else{
before_ground_angle = atan2(x - last_x, y - last_y)*180/M_PI;
angle = before_ground_angle - 90;
}
x_0 = x;
y_0 = 50;
count_0 = 0;
bounceCount++;
if (bounceCount >= 1){
if (bounceCount >= 2){
v0 *= 0.9;
}
if (bounceCount == 2 && !stop){
if (x > 2046){
p2_bounce_twice = true;
stop = true;
}
else {
p1_bounce_twice = true;
stop = true;
}
}
if (bounceCount == 1 && !stop){
if (x > 2046 && !player){
p2_own_side = true;
stop = true;
}
else if (x <= 2046 && player){
p1_own_side = true;
stop = true;
}
}
}
}
if (bounceCount > 3){
x = gameStartX;
x_0 = x;
y = 1000;
y_0 = y;
v0 = 0;
g = 0;
net = false;
player = server;
bounce = 0;
bounceCount = 0;
stop = false;
}
// Bounce if the ball hits the net
if (y < 513 && (x > 2006 && x < 2086)) {
net = true;
if (x < 2046) x_0 = 2006;
else if (x > 2046) x_0 = 2086;
y_0 = y;
v0 = 0.5*v0;
angle += 180;
count_0 = 0;
bounce = 1;
}
// Out of bounds
if (x < 0 || x > 4080) {
if (!p1_bounce_twice && !p2_bounce_twice){
if (player && bounceCount < 1 && !stop){
p1_out = true;
stop = true;
}
else if (!player && bounceCount < 1 && !stop){
p2_out = true;
stop = true;
}
else if (player && bounceCount == 1 && !stop){
p2_once_out = true;
stop = true;
}
else if (!player && bounceCount == 1 && !stop){
p1_once_out = true;
stop = true;
}
}
x = gameStartX;
x_0 = x;
y = 1000;
y_0 = y;
v0 = 0;
g = 0;
net = false;
player = server;
bounce = 0;
bounceCount = 0;
stop = false;
}
if (y < 0 || y > 4080) {
x = gameStartX;
x_0 = x;
y = 1000;
y_0 = y;
v0 = 0;
g = 0;
net = false;
player = server;
bounce = 0;
bounceCount = 0;
stop = false;
}
last_y= y;
last_x= x;
count_0++;
if (p1_out || p1_bounce_twice || p1_own_side || p1_once_out){
score_2++;
p1_out = false;
p1_own_side = false;
p1_bounce_twice = false;
p1_once_out = false;
}
if (p2_out || p2_bounce_twice || p2_own_side || p2_once_out){
score_1++;
p2_out = false;
p2_own_side = false;
p2_bounce_twice = false;
p2_once_out = false;
}
int oldScore1 = score_1;
switch(score_1){
case 0:
break;
case 1:
gpio_put(GREEN1, 1);
break;
case 2:
gpio_put(GREEN2, 1);
break;
case 3:
gpio_put(GREEN3, 1);
if (score_2 == 3){
gpio_put(GREEN3, 0);
score_1 = 2;
}
break;
case 4:
gpio_put(GREEN4, 1);
gameOver = true;
changeFromStart = false;
winner = false;
break;
}
switch(score_2){
case 0:
break;
case 1:
gpio_put(RED1, 1);
break;
case 2:
gpio_put(RED2, 1);
break;
case 3:
gpio_put(RED3, 1);
if (oldScore1 == 3){
gpio_put(RED3, 0);
score_2 = 2;
}
break;
case 4:
gpio_put(RED4, 1);
gameOver = true;
changeFromStart = false;
winner = true;
break;
}
gpio_put(LED, changeFromStart);
}
}
PT_END(pt);
}
char keytext[100];
static PT_THREAD (protothread_serial(struct pt *pt))
{
PT_BEGIN(pt) ;
while(1) {
sprintf(pt_serial_out_buffer, "ground: %d; before ground: %d\n", angle, score_2);
serial_write;
}
PT_END(pt) ;
}
// This timer ISR is called on core 0
bool repeating_timer_net(struct repeating_timer *t) {
if (!gameOver){
DAC_output_1 = 50;
// Draw the ground
DAC_output_0 = 0;
while (DAC_output_0 < 4096)
{
DAC_data_x = (DAC_config_chan_A | (DAC_output_0 & 0xffff));
DAC_data_y = (DAC_config_chan_B | (DAC_output_1 & 0xffff));
// SPI write (no spinlock b/c of SPI buffer)
spi_write16_blocking(SPI_PORT, &DAC_data_x, 1) ;
spi_write16_blocking(SPI_PORT, &DAC_data_y, 1) ;
DAC_output_0 += 3;
}
// Draw the net
DAC_output_0 = 2046;
while (DAC_output_1 < 513){
DAC_data_x = (DAC_config_chan_A | (DAC_output_0 & 0xffff));
DAC_data_y = (DAC_config_chan_B | (DAC_output_1 & 0xffff));
// SPI write (no spinlock b/c of SPI buffer)
spi_write16_blocking(SPI_PORT, &DAC_data_x, 1) ;
spi_write16_blocking(SPI_PORT, &DAC_data_y, 1) ;
DAC_output_1 += 2;
}
}
return true;
}
// Core 0 entry point
int main() {
// Initialize stdio/uart (printf won't work unless you do this!)
stdio_init_all();
printf("Hello, friends!\n");
// Initialize SPI channel (channel, baud rate set to 20MHz)
spi_init(SPI_PORT, 20000000) ;
// Format (channel, data bits per transfer, polarity, phase, order)
spi_set_format(SPI_PORT, 16, 0, 0, 0);
// Map SPI signals to GPIO ports
gpio_set_function(PIN_MISO, GPIO_FUNC_SPI);
gpio_set_function(PIN_SCK, GPIO_FUNC_SPI);
gpio_set_function(PIN_MOSI, GPIO_FUNC_SPI);
gpio_set_function(PIN_CS, GPIO_FUNC_SPI) ;
// Set up pin for timing ISR
gpio_init(ISR_time);
gpio_set_dir(ISR_time, GPIO_OUT);
gpio_put(ISR_time, 0);
// Map LDAC pin to GPIO port, hold it low (could alternatively tie to GND)
gpio_init(LDAC) ;
gpio_set_dir(LDAC, GPIO_OUT) ;
gpio_put(LDAC, 0) ;
// Map LED to GPIO port, make it low
gpio_init(LED) ;
gpio_set_dir(LED, GPIO_OUT) ;
gpio_put(LED, 0) ;
// Set up button pins
gpio_init(B1);
gpio_set_dir(B1, GPIO_IN);
gpio_pull_down(B1);
gpio_init(B2);
gpio_set_dir(B2, GPIO_IN);
gpio_pull_down(B2);
//LEDs for Scoring
gpio_init(RED1) ;
gpio_set_dir(RED1, GPIO_OUT) ;
gpio_init(RED2) ;
gpio_set_dir(RED2, GPIO_OUT) ;
gpio_init(RED3) ;
gpio_set_dir(RED3, GPIO_OUT) ;
gpio_init(RED4) ;
gpio_set_dir(RED4, GPIO_OUT) ;
gpio_init(GREEN1) ;
gpio_set_dir(GREEN1, GPIO_OUT) ;
gpio_init(GREEN2) ;
gpio_set_dir(GREEN2, GPIO_OUT) ;
gpio_init(GREEN3) ;
gpio_set_dir(GREEN3, GPIO_OUT) ;
gpio_init(GREEN4) ;
gpio_set_dir(GREEN4, GPIO_OUT) ;
gpio_put(RED1, 0);
gpio_put(RED2, 0);
gpio_put(RED3, 0);
gpio_put(RED4, 0);
gpio_put(GREEN1, 0);
gpio_put(GREEN2, 0);
gpio_put(GREEN3, 0);
gpio_put(GREEN4, 0);
// Set up the ADC
adc_init();
adc_gpio_init(KNOB_1);
adc_gpio_init(KNOB_2);
// Build the sine lookup table
// scaled to produce values between 0 and 4096 (for 12-bit DAC)
int ii;
for (ii = 0; ii < sine_table_size; ii++){
sin_table[ii] = float2fix15(2047*sin((float)ii*6.283/(float)sine_table_size));
}
// Create a repeating timer that calls
// repeating_timer_callback (defaults core 0)
struct repeating_timer timer_core_0;
// Negative delay so means we will call repeating_timer_callback, and call it
// again 25us (40kHz) later regardless of how long the callback took to execute
add_repeating_timer_us(-7000,
repeating_timer_callback_core_0, NULL, &timer_core_0);
struct repeating_timer timer_net;
add_repeating_timer_us(-15000,
repeating_timer_net, NULL, &timer_net);
// Add core 0 threads
pt_add_thread(protothread_serial) ;
pt_add_thread(game_logic);
// Start scheduling core 0 threads
pt_schedule_start ;
}
File "<ipython-input-2-6ef297f585ce>", line 1 **() ^ SyntaxError: invalid syntax