Super Smash Bros Melee, a nearly 20 year old game for the Nintendo Gamecube, has experienced a boom in the quarantine era due to a community-developed mod that adds rollback netcode. While other, newer games have stuggled due the challenges of online tournaments and lag, Melee is easier to play than ever. Controller "technology" has also been advancing recently; acquiring and maintaining a "good" controller had been an expensive endeavor for top players, but several projects have started to reduce this cost. Digital controllers, such as the Frame One, B0XX, and Smashbox, are designed to be ergonomic and reliable, doing away with the analog joystick components that are usually the first parts to wear out and require replacement, and providing an alternative layout that may cause less hand strain on players. Another project, the Goomwave DX, replaces the internal electronics of a standard controller with new hardware and firmware that allows players to recalibrate the analog inputs to account for wear.
I was interested in experimenting with a digital controller, but the commercially available ones are still fairly expensive (generally ~200USD). The electronics and hardware are fundamentally simple, so I thought I could make a version that sacrificed build quality for price, using 3D printing, an Arduino, and some arcade buttons I had lying around:
To make the body of the controller, I used an image of the B0XX and traced the layout in Fusion 360. I then added a bunch of cut-outs for aesthetics and holes and channels for the buttons and internal wiring. At the top, I added a small chamber to house the rest of the electronics. Finally, the controller is too large for my 3D printer, so I split the controller into three pieces that I could print one at a time and then weld back together with a soldering iron. A 3D printer isn't required to make your own controller; the frame could be made out of almost anything (and indeed I have seen similar projects using cardboard boxes). I had everything I needed laying around from previous projects, so my build cost me 0USD! However, assuming you don't have any particular parts on hand (but have access to a 3D printer, a soldering iron, and some wire already), it would still be a pretty cheap build. Here is a rough BoM of components you will probably need to get for this project:
Part | Price | Note |
---|---|---|
GCC cable and connector | $5 | taken from a cheap GCC extension cable |
Arduino Pro Mini | $5 | I've seen them for $2 on AliExpress |
Logic Level Converter | $10 for 10 pieces | not sure if you can get single pieces as cheaply |
Arcade Buttons | $5 | I got really cheap ones from AliExpress |
The wiring itself is also straightforward; each button has two leads, one is connected to ground and the other to a unique pin on the Arduino. The ground connections can all be daisy-chained, which reduces the amount of wiring significantly. I used an Arduino Pro Mini clone, which didn't have enough digital inputs for all the buttons, so I had to forego the mid-shield button (most of the analog pins can be used as digital inputs, but not all of them). The connection to the Gamecube is done via a cable and connector that I harvested from an old, crappy controller (these could probably be acquired for ~10USD online). The controller cable has the following wires: GND, 3.3V, 5V, and Data (which is 3.3V). The Arduino I used is 5V, so I needed to put a bidirectional logic level converter in line on the data wire (with a 1K pull-up resistor on the 3.3V side) so I don't damage any electronics. One thing to note is that the 5V line is only used for the rumble motor in a standard controller, so that needs to be supported if you are using a PC adapter.
The last step was the firmware (the source is available at the bottom of this post). There is an open source library that handles the Gamecube controller protocol, so all I had to do was read the button state and convert that to a controller state. For all the digital inputs on a standard controller, the physical button state is just copied to the corresponding controller button state, but analog inputs are a bit more complicated, and there are multiple ways to implement them. The first step is just to have the 4 cardinal direction buttons combine into 8 total directions (plus the one neutral position), and include logic that uses the most recent button if two conflicting buttons were pressed. Finally, there are modifier buttons that change the exact positioning of those 8 directions (two dedicated modifiers and the R/L buttons for shield-specific angles), and I programmed them specific to be specific to Melee. I implemented this by first assigning an index to each of the 8+1 general positions, and then using a lookup table for each of the modifier buttons states to choose the final analog values. I used the B0XX documentation as a reference for the desireable angles, but I didn't implement the B0XX's system exactly and there is probably some room for improvement on the analog implementation and specific values.
#include <Arduino.h> #include "Nintendo.h" CGamecubeConsole gc_console(8); CGamecubeController gc_ctrl(A6); void setup() { pinMode(0, INPUT_PULLUP); pinMode(1, INPUT_PULLUP); pinMode(2, INPUT_PULLUP); pinMode(3, INPUT_PULLUP); pinMode(4, INPUT_PULLUP); pinMode(5, INPUT_PULLUP); pinMode(6, INPUT_PULLUP); pinMode(7, INPUT_PULLUP); pinMode(9, INPUT_PULLUP); pinMode(10, INPUT_PULLUP); pinMode(11, INPUT_PULLUP); pinMode(12, INPUT_PULLUP); pinMode(13, INPUT_PULLUP); pinMode(A0, INPUT_PULLUP); pinMode(A1, INPUT_PULLUP); pinMode(A2, INPUT_PULLUP); pinMode(A3, INPUT_PULLUP); pinMode(A4, INPUT_PULLUP); pinMode(A5, INPUT_PULLUP); pinMode(A7, INPUT_PULLUP); //This is needed to run the code. gc_ctrl.read(); } typedef union button_state_t { struct { uint8_t port_d : 8; uint8_t port_b : 8; uint8_t port_c : 8; }; struct { bool B : 1; bool A : 1; bool X : 1; bool Y : 1; bool Z : 1; bool L : 1; bool R : 1; bool Start : 1; // 8-13 bool _dummy0 : 1; bool Left : 1; bool Up : 1; bool Down : 1; bool ModX : 1; bool ModY : 1; bool _dummy1 : 2; // A0-A7 bool RLight : 1; bool CLeft : 1; bool CRight : 1; bool CUp : 1; bool CDown : 1; bool Right : 1; }; } button_state_t; // controller state variables button_state_t next = {0}, prev = {0}; Gamecube_Data_t gcc_state = defaultGamecubeData; #define AV(x) (128 + (x)) #define FAV(x) (128 + (int8_t) (80.0 * (x))) // analog value references const uint8_t axis_x_table[8][9] = { // NN ZN PN NZ ZZ PZ NP ZP PP {FAV( -0.7), FAV( 0), FAV( 0.7), FAV( -1.0), FAV( 0), FAV( 1.0), FAV( -0.7), FAV( 0), FAV( 0.7)}, // Default {FAV(-0.7375), FAV( 0), FAV( 0.7375), FAV(-0.6625), FAV( 0), FAV( 0.6625), FAV(-0.7375), FAV( 0), FAV( 0.7375)}, // ModX {FAV(-0.3125), FAV( 0), FAV( 0.3125), FAV(-0.3375), FAV( 0), FAV( 0.3375), FAV(-0.3125), FAV( 0), FAV( 0.3125)}, // ModY {FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0)}, // ModX + ModY {FAV( -0.7), FAV( 0), FAV( 0.7), FAV( -1.0), FAV( 0), FAV( 1.0), FAV( -0.7), FAV( 0), FAV( 0.7)}, // R/L {FAV(-0.6375), FAV( 0), FAV( 0.6375), FAV(-0.6625), FAV( 0), FAV( 0.6625), FAV(-0.6375), FAV( 0), FAV( 0.6375)}, // ModX + R/L (Sheild Tilt) {FAV(-0.6375), FAV( 0), FAV( 0.6375), FAV(-0.6625), FAV( 0), FAV( 0.6625), FAV(-0.6375), FAV( 0), FAV( 0.6375)}, // ModY + R/L (Shield Dropping) {FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0)} // ModX + ModY + R/L }; const uint8_t axis_y_table[8][9] = { // NN ZN PN NZ ZZ PZ NP ZP PP {FAV( -0.7), FAV( -1.0), FAV( -0.7), FAV( 0), FAV( 0), FAV( 0), FAV( 0.7), FAV( 1.0), FAV( 0.7)}, // Default {FAV(-0.3125), FAV(-0.5375), FAV(-0.3125), FAV( 0), FAV( 0), FAV( 0), FAV( 0.3125), FAV( 0.5375), FAV( 0.3125)}, // ModX {FAV(-0.7375), FAV(-0.7375), FAV(-0.7375), FAV( 0), FAV( 0), FAV( 0), FAV( 0.7375), FAV( 0.7375), FAV( 0.7375)}, // ModY {FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0)}, // ModX + ModY {FAV( -0.7), FAV( -1.0), FAV( -0.7), FAV( 0), FAV( 0), FAV( 0), FAV( 0.7), FAV( 1.0), FAV( 0.7)}, // R/L {FAV(-0.3750), FAV(-0.5375), FAV(-0.3750), FAV( 0), FAV( 0), FAV( 0), FAV( 0.3750), FAV( 0.5375), FAV( 0.3750)}, // ModX + R/L (Shield Tilt) {FAV(-0.6625), FAV(-0.6625), FAV(-0.6625), FAV( 0), FAV( 0), FAV( 0), FAV( 0.3750), FAV( 0.5375), FAV( 0.3750)}, // ModY + R/L (Shield Dropping) {FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0), FAV( 0)} // ModX + ModY + R/L }; // bit order: LRUD // None L R LR U LU RU LRU D LD RD LRD UD LUD RUD LRUD const uint8_t cstick_x_table[16] = {FAV(0.0), FAV(-1.0), FAV(1.0), FAV(0.0), FAV(0.0), FAV(-0.525), FAV(0.525), FAV(0.0), FAV(0.0), FAV(-0.525), FAV(0.525), FAV(0.0), FAV(0.0), FAV(-1.0), FAV(1.0), FAV(0.0)}; const uint8_t cstick_y_table[16] = {FAV(0.0), FAV(0.0), FAV(0.0), FAV(0.0), FAV(1.0), FAV(0.85), FAV(0.85), FAV(1.0), FAV(-1.0), FAV(-0.85), FAV(-0.85), FAV(-1.0), FAV(0.0), FAV(0.0), FAV(0.0), FAV(0.0)}; const int light_shield_v = 74; const int X1v = 27; const int X2v = 55; const int X3v = 73; const int Y1v = 27; const int Y2v = 53; const int Y3v = 74; bool most_recently_pressed_left; bool most_recently_pressed_down; void loop() { //read buttons prev = next; next.port_d = ~PIND; next.port_b = ~PINB; next.port_c = ~PINC; // process button state // binary buttons are simple gcc_state.report.a = next.A; gcc_state.report.b = next.B; gcc_state.report.x = next.X; gcc_state.report.y = next.Y; gcc_state.report.z = next.Z; gcc_state.report.start = next.Start; gcc_state.report.r = next.R; gcc_state.report.l = next.L; // set the trigger analog value gcc_state.report.right = (next.RLight) ? light_shield_v : 0; // modifier button state uint8_t mod_index = 0; mod_index |= (next.ModX) ? 1 : 0; mod_index |= (next.ModY) ? 2 : 0; mod_index |= (next.R || next.L || next.RLight) ? 4 : 0; // calculate the c-stick state uint8_t c_stick_index = 0; c_stick_index |= (next.CLeft) ? 1 : 0; c_stick_index |= (next.CRight) ? 2 : 0; c_stick_index |= (next.CUp) ? 4 : 0; c_stick_index |= (next.CDown) ? 8 : 0; if (mod_index == 3) { // if both modifiers are pressed (and none of the trigger buttons), the c-stick buttons are mapped to the dpad instead gcc_state.report.dleft = next.CLeft; gcc_state.report.dright = next.CRight; gcc_state.report.dup = next.CUp; gcc_state.report.ddown = next.CDown; gcc_state.report.cxAxis = FAV(0); gcc_state.report.cyAxis = FAV(0); } else { // normal c-stick operation gcc_state.report.dleft = false; gcc_state.report.dright = false; gcc_state.report.dup = false; gcc_state.report.ddown = false; gcc_state.report.cxAxis = cstick_x_table[c_stick_index]; gcc_state.report.cyAxis = cstick_y_table[c_stick_index]; } // calculate the control stick state bool left_pressed = next.Left && (!prev.Left); bool right_pressed = next.Right && (!prev.Right); bool up_pressed = next.Up && (!prev.Up); bool down_pressed = next.Down && (!prev.Down); most_recently_pressed_left |= left_pressed; most_recently_pressed_left &= !(right_pressed); most_recently_pressed_down |= down_pressed; most_recently_pressed_down &= !(up_pressed); bool x_conflict = next.Left && next.Right; bool y_conflict = next.Up && next.Down; uint8_t x_index = 1; uint8_t y_index = 1; x_index = (x_conflict && most_recently_pressed_left) ? 0 : x_index; x_index = (x_conflict && !most_recently_pressed_left) ? 2 : x_index; x_index = (!x_conflict && next.Left) ? 0 : x_index; x_index = (!x_conflict && next.Right) ? 2 : x_index; y_index = (y_conflict && most_recently_pressed_down) ? 0 : y_index; y_index = (y_conflict && !most_recently_pressed_down) ? 2 : y_index; y_index = (!y_conflict && next.Down) ? 0 : y_index; y_index = (!y_conflict && next.Up) ? 2 : y_index; uint8_t analogue_index = x_index + 3 * y_index; gcc_state.report.xAxis = axis_x_table[mod_index][analogue_index]; gcc_state.report.yAxis = axis_y_table[mod_index][analogue_index]; gc_console.write(gcc_state); }