Greetings everyone, and welcome back.
This is PICO VGA BLASTER, a DIY Retro Console that runs arcade-style games on a VGA Monitor.
This project was based on my previously created PICO VGA Board, in which a Raspberry Pi Pico is connected to a DSUB15 connector for driving a VGA monitor and using it as a regular display. By taking that project and adding a button board along with a few edits, I was able to create this retro-styled console.
I prepared a custom game (PICO VGA BLASTER) specially made for this console. The game is loosely based on the original Space Invaders, but with a little twist: the aliens are VGA port-shaped entities that have invaded Earth, and we have to defend it.
My console contains six buttons: four directional buttons for controlling the spacecraft and two buttons for firing missiles and bullet projectiles.
A game without lore feels boring, so I also created a complete backstory and intro sequence for the game, giving the whole experience a retro arcade campaign feel.
For power, we need a 12V input provided by the DC barrel jack on the VGA board, and for the monitor, we are using a regular 27-inch LED Monitor.
For the housing, I even designed a custom enclosure, then 3D printed the parts and assembled everything together. This article covers the entire build process of this project, so let’s get started with the build.
MATERIALS REQUIREDThese are the components used in this project
- VGA PICO BOARD PCB (Provided by NEXTPCB)
- BUTTON BOARD (Provided by NEXTPCB)
- 12x12 tactile push buttons
- WS2812B LEDs
- 100nF Capacitors
- 3D Printed parts
- 12V Adaptor
- D-SUB 15 VGA Connector
- DC Barrel jack
- 330 Ohms Resistor 1206 Package
- 10k Resistor 0805 Package
- 1K Resistor 1206 Package
- LED Green 0603
- Female header Pins CON20 x 2
- PICO 1 or PICO W (any with RP2040)
The star of our project is the previously created PICO VGA BOARD 1.0, in which I paired a D-SUB15 connector with a Raspberry Pi Pico for driving a VGA monitor and using it just like any OLED or LCD screen. With this board, we can integrate a full-size VGA monitor into our projects.
For more information on the setup and working of this VGA Board, check out its article using the link below.
https://www.hackster.io/Arnov_Sharma_makes/pico-vga-board-1-0-0c121c#toc-materials-required-0
PCB DESIGN - PICO VGA BOARD 1.0Let’s have a look at the PICO VGA BOARD 1.0 PCB Design. I connected a D-Sub 15-pin VGA connector directly to the Raspberry Pi Pico to generate VGA video output.
VGA works using three analog signals: Red, Green, and Blue. Each signal line carries a voltage ranging from 0V to 0.7V, where 0V means no intensity and 0.7V means full intensity. By combining these three signals, the monitor can reproduce different colors for every pixel. For example, setting only the red line to 0.7V produces pure red, while setting all three lines to 0.7V produces white.
Apart from RGB signals, VGA also requires two digital synchronization signals: HSYNC (Horizontal Sync) and VSYNC (Vertical Sync). HSYNC tells the monitor when a new horizontal line starts, while VSYNC indicates the start of a new frame. The monitor continuously draws pixels line by line, refreshing the entire screen around 60 times per second.
I connected Pico GPIO18, GPIO19, and GPIO20 to Pin 1, Pin 2, and Pin 3 of the VGA connector for the Red, Green, and Blue signals. Between these connections, I added resistor networks for proper voltage control. Each RGB line uses three 330Ω resistors connected in parallel, allowing the resistance value to be adjusted easily by adding or removing resistors while keeping all RGB channels balanced.
For synchronization, Pin 13 of the VGA connector, which is HSYNC, is connected to GPIO16 of the Pico, while Pin 14, which is VSYNC, is connected to GPIO17. Pins 5, 6, 7, 8, and 10 are all connected to ground.
I also added a CON15 breakout connector linked to all 15 VGA pins, making it easier to access individual VGA signals for testing or future expansion. Alongside that, I included additional headers connected to unused GPIO pins of the Pico so they can be used later for other peripherals or future projects.
For power regulation, I added an LM317 adjustable voltage regulator to the board. This regulator is capable of supplying more than 1.5A of current and supports an adjustable output voltage range from 1.25V to 37V.
We have set up the LM317 in such a way that when we input 12V, we get a stable 5V for powering the Pico.
https://www.ti.com/lit/ds/symlink/lm317.pdf
PCB DESIGN - BUTTON BOARDThe second PCB I used in this project was the Button Board, which I reused from my Motorola DynaTAC project. Here, I added six buttons onto a custom PCB. All buttons are connected to GND, while their other pins are routed to a CON7 connector, which is used to interface the buttons with the microcontroller — in this case, the PICO VGA Board.
We also added six WS2812B RGB LEDs for additional lighting effects and visual feedback.
PCB ASSEMBLY PROCESS - VGA DRIVER- We begin the PCB assembly process by first adding solder paste to each component’s pads one by one using a solder paste dispensing needle. Here, we are using 63/37 SnPb solder paste.
- We then pick and place each SMD component in its correct location.
- The PCB is then placed on a reflow hotplate, which heats the PCB from below up to the solder paste melting temperature. As soon as the PCB reaches that temperature, the solder paste melts, and all components are secured in their positions.
- For the through-hole assembly process, we added the D-Sub 15 connector in its position, followed by the barrel DC jack, and two CON20 female header pins in place of the Pico. By turning the board over and using a soldering iron, we solder all the through-hole component leads, securing everything in position.
- At last, we place the Pico W in its position over the female header pins. This completes the assembly process of the driver board.
To run the DEMO CODE on our VGA Board, we plug the VGA cable from the monitor into the VGA port of our circuit, and then connect the power adapter via the DC barrel jack connector.
Here's the Code we used for DEMO, and it's a simple one.
#include "vga_graphics.h"
/* ── Screen ───────────────── */
#define SW 640
#define SH 480
/* ── Grid ───────────────── */
#define CELL 8
#define GRID_W (SW / CELL)
#define GRID_H (SH / CELL)
/* ── Buffers ───────────── */
bool grid[GRID_W][GRID_H];
bool nextGrid[GRID_W][GRID_H];
/* ── Draw cell ─────────── */
void drawCell(int x, int y, bool alive) {
fillRect(
x * CELL,
y * CELL,
CELL - 1,
CELL - 1,
alive ? YELLOW : BLACK
);
}
/* ── Random init ───────── */
void randomizeGrid() {
for (int x = 0; x < GRID_W; x++) {
for (int y = 0; y < GRID_H; y++) {
grid[x][y] = random(0, 2);
drawCell(x, y, grid[x][y]);
}
}
}
/* ── Count neighbors ───── */
int countNeighbors(int x, int y) {
int count = 0;
for (int dx = -1; dx <= 1; dx++) {
for (int dy = -1; dy <= 1; dy++) {
if (dx == 0 && dy == 0) continue;
int nx = x + dx;
int ny = y + dy;
// wrap around edges
if (nx < 0) nx = GRID_W - 1;
if (nx >= GRID_W) nx = 0;
if (ny < 0) ny = GRID_H - 1;
if (ny >= GRID_H) ny = 0;
if (grid[nx][ny]) count++;
}
}
return count;
}
/* ── Update simulation ─── */
void updateGrid() {
for (int x = 0; x < GRID_W; x++) {
for (int y = 0; y < GRID_H; y++) {
int neighbors = countNeighbors(x, y);
if (grid[x][y]) {
// alive
nextGrid[x][y] = (neighbors == 2 || neighbors == 3);
} else {
// dead
nextGrid[x][y] = (neighbors == 3);
}
}
}
// apply + redraw only changes
for (int x = 0; x < GRID_W; x++) {
for (int y = 0; y < GRID_H; y++) {
if (grid[x][y] != nextGrid[x][y]) {
drawCell(x, y, nextGrid[x][y]);
}
grid[x][y] = nextGrid[x][y];
}
}
}
/* ── Setup ─────────────── */
void setup() {
initVGA();
clearScreen();
randomSeed(analogRead(26)); // Pico randomness
randomizeGrid();
}
/* ── Loop ─────────────── */
void loop() {
updateGrid();
delay(80); // speed control
}Code begins by defining the screen resolution, which is the following.
#define SW 640
#define SH 480We next define the grid size for cells. Each cell is 8x8 Pixels, the grid becomes 640/8 = 80 columns and 480/8 = 60 rows.
#define CELL 8
#define GRID_W (SW / CELL)
#define GRID_H (SH / CELL)Below is the buffer or core of our simulation. This prevents overwriting data while calculating.
bool grid[GRID_W][GRID_H];
bool nextGrid[GRID_W][GRID_H];Next up is drawing a cell logic. This first converts the grid position into screen pixels.
void drawCell(int x, int y, bool alive)Using this, a square is drawn.
x * CELL, y * CELLThis is a random function that fills the grid randomly when the code starts.
void randomizeGrid()This is the core logic of the game of life, which is counting neighbours. It checks all 8 surrounding cells.
int countNeighbors(int x, int y)Game Rules are set with the following.
Alive Cell survives if there are 2 or 3 neighbours. Dead cells become alive if there are exactly 3 neighbours.
if (grid[x][y]) {
nextGrid[x][y] = (neighbors == 2 || neighbors == 3);
} else {
nextGrid[x][y] = (neighbors == 3);
}Using the section below, we only redraw the cells that are changed instead of a full-screen redraw.
if (grid[x][y] != nextGrid[x][y]) {
drawCell(x, y, nextGrid[x][y]);
}This is the copy state that moves next from the current frame.
grid[x][y] = nextGrid[x][y];In the setup, VGA is initialized, randomness is added, and the simulation starts.
initVGA();
clearScreen();
randomSeed(analogRead(26));
randomizeGrid();In the loop, the simulation is updated continuously, and its speed is controlled. Lower delay means fast evolution, and higher delay results in a slower, easier-to-see evolution.
updateGrid();
delay(80);PCB ASSEMBLY PROCESS- BUTTON BOARD- Button board assembly begins by applying solder paste to the SMD LED and capacitor pads.
- All the SMD components are then placed in their correct positions, six 100 nF decoupling capacitors and six WS2812B RGB LEDs, using ESD-safe tweezers.
- The entire board is then placed on a reflow hotplate, which heats the PCB from below up to the solder paste’s melting temperature. Once the PCB reaches this temperature, the solder paste melts, and all the SMD components are securely soldered in place.
- We place 12×12 mm push buttons in their designated location.
- After placing the through-hole switches, we flipped the boards over and soldered each lead using a soldering iron.
This completed the Button Board assembly process.
MAIN ELECTRONICS SETUPFor the main wiring of our setup, we first connected the GND of the VGA Board with the GND of the Button Board so both PCBs could share a common ground connection.
The button connections were done as follows:
- Fire Button to GPIO0
- UP Button to GPIO1
- Missile Button to GPIO2
- LEFT Button to GPIO3
- DOWN Button to GPIO4
- RIGHT Button to GPIO5
For the RGB lighting setup, the DIN pin of the WS2812B LED was connected to GPIO6, while its VCC pin was connected to the 5V output of the Raspberry Pi Pico.
For all the wiring connections, we used single-core silver copper wire along with a soldering iron to securely solder every connection in place.
GAME CODEThis is the main code for our project, which contains the game logic and functionality.
#include "vga_graphics.h"
#include "hardware/pwm.h"
#include "hardware/clocks.h"
#include "hardware/gpio.h"
#include "hardware/sync.h"
#include <stdio.h>
#include <string.h>
// ── Screen ────────────────────────────────────────────────────────────────────
#define SW 640
#define SH 480
// ── Buttons (active LOW — internal pull-up) ───────────────────────────────────
#define BTN_FIRE 0
#define BTN_MISSILE 2
#define BTN_LEFT 3
#define BTN_RIGHT 5
#define BTN_UP 1
#define BTN_DOWN 4
void buttonsInit() {
const uint btns[] = {BTN_FIRE, BTN_MISSILE, BTN_LEFT, BTN_RIGHT, BTN_UP, BTN_DOWN};
for (int i = 0; i < 6; i++) {
gpio_init(btns[i]);
gpio_set_dir(btns[i], GPIO_IN);
gpio_pull_up(btns[i]);
}
}
inline bool btnPressed(uint pin) { return !gpio_get(pin); }
// ── WS2812B LED Strip on GPIO 6 ───────────────────────────────────────────────
#define LED_PIN 6
#define NUM_LEDS 8 // <--- CHANGE THIS to the actual number of LEDs in your strip!
uint8_t strip_r[NUM_LEDS];
uint8_t strip_g[NUM_LEDS];
uint8_t strip_b[NUM_LEDS];
void ws2812Init() {
gpio_init(LED_PIN);
gpio_set_dir(LED_PIN, GPIO_OUT);
gpio_put(LED_PIN, 0);
for(int i=0; i<NUM_LEDS; i++) { strip_r[i]=0; strip_g[i]=0; strip_b[i]=0; }
}
// Custom bit-banger that perfectly respects VGA interrupts
static inline void ws2812_send_bit(bool bit) {
if (bit) {
uint32_t saved = save_and_disable_interrupts();
gpio_put(LED_PIN, 1);
asm volatile (
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
); // ~720ns High
gpio_put(LED_PIN, 0);
restore_interrupts(saved); // Allow VGA interrupt here!
asm volatile (
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
); // Low
} else {
uint32_t saved = save_and_disable_interrupts();
gpio_put(LED_PIN, 1);
asm volatile (
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\n"
); // ~280ns High
gpio_put(LED_PIN, 0);
restore_interrupts(saved); // Allow VGA interrupt here!
asm volatile (
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
"nop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\nnop\n"
); // Low
}
}
// Set a specific LED color in memory
void setLedColor(int index, uint8_t r, uint8_t g, uint8_t b) {
if(index >= 0 && index < NUM_LEDS) {
strip_r[index] = r;
strip_g[index] = g;
strip_b[index] = b;
}
}
// Push the memory to the actual strip
void showLeds() {
for(int led = 0; led < NUM_LEDS; led++) {
uint32_t grb = ((uint32_t)strip_g[led]<<16) | ((uint32_t)strip_r[led]<<8) | (uint32_t)strip_b[led];
for (int i=23; i>=0; i--) ws2812_send_bit((grb>>i)&1);
}
gpio_put(LED_PIN, 0);
sleep_us(60); // Latch signal
}
// LED fade state
bool ledFadeActive = false;
unsigned long ledFadeStart = 0;
const unsigned long ledFadeDuration = 2000; // total ms
void triggerLedFade() {
ledFadeActive = true;
ledFadeStart = millis();
}
void updateLed() {
static bool ledsAreOff = false;
if (!ledFadeActive) {
if (!ledsAreOff) {
for(int i=0; i<NUM_LEDS; i++) setLedColor(i, 0, 0, 0);
showLeds();
ledsAreOff = true;
}
return;
}
ledsAreOff = false;
unsigned long t = millis() - ledFadeStart;
if (t >= ledFadeDuration) {
ledFadeActive = false;
for(int i=0; i<NUM_LEDS; i++) setLedColor(i, 0, 0, 0);
showLeds();
ledsAreOff = true;
return;
}
uint8_t brightness;
if (t < ledFadeDuration / 2) {
brightness = (uint8_t)((t * 255UL) / (ledFadeDuration / 2)); // fade in
} else {
brightness = (uint8_t)(((ledFadeDuration - t) * 255UL) / (ledFadeDuration / 2)); // fade out
}
// Set all LEDs to the current fade brightness
for(int i=0; i<NUM_LEDS; i++) {
setLedColor(i, brightness, 0, 0);
}
showLeds();
}
// ── Buzzer ────────────────────────────────────────────────────────────────────
#define BUZZER_PIN 15
void buzzerInit() {
gpio_set_function(BUZZER_PIN, GPIO_FUNC_PWM);
uint slice = pwm_gpio_to_slice_num(BUZZER_PIN);
pwm_set_enabled(slice, false);
}
void playTone(uint freq) {
if (freq == 0) { pwm_set_enabled(pwm_gpio_to_slice_num(BUZZER_PIN), false); return; }
uint slice = pwm_gpio_to_slice_num(BUZZER_PIN);
uint chan = pwm_gpio_to_channel(BUZZER_PIN);
uint32_t top = clock_get_hz(clk_sys) / freq - 1;
if (top > 65535) top = 65535;
pwm_set_wrap(slice, top);
pwm_set_chan_level(slice, chan, top / 2);
pwm_set_enabled(slice, true);
}
void stopTone() { pwm_set_enabled(pwm_gpio_to_slice_num(BUZZER_PIN), false); }
// ── Forward declarations ───────────────────────────────────────────────────────
void checkLives();
void drawGameOverScreen();
void startWave(int w);
// ── Non-blocking tone ─────────────────────────────────────────────────────────
unsigned long toneEndMs = 0;
void beep(uint freq, uint ms) { playTone(freq); toneEndMs = millis() + ms; }
void updateBuzzer() {
if (toneEndMs && millis() >= toneEndMs) { stopTone(); toneEndMs = 0; }
}
// ── Intro music ───────────────────────────────────────────────────────────────
struct Note { uint freq; uint dur; };
const Note introMusic[] = {
{330,120},{0,40},{262,120},{0,40},{220,120},{0,40},{196,200},{0,80},
{330,100},{0,30},{294,100},{0,30},{262,150},{0,50},{247,120},{0,40},
{220,300},{0,100},
{392,100},{0,30},{370,100},{0,30},{330,150},{0,50},{294,120},{0,40},
{262,80},{0,20},{247,80},{0,20},{220,80},{0,20},{196,300},{0,150},
{165,120},{0,40},{196,120},{0,40},{220,120},{0,40},{247,120},{0,40},
{262,200},{0,80},{220,400},{0,200}
};
const int NUM_NOTES = sizeof(introMusic)/sizeof(Note);
int musicNoteIdx = 0;
unsigned long musicNoteEnd = 0;
bool musicPlaying = false;
void startIntroMusic() { musicNoteIdx=0; musicNoteEnd=0; musicPlaying=true; }
void stopIntroMusic() { musicPlaying=false; stopTone(); toneEndMs=0; }
void updateIntroMusic() {
if (!musicPlaying) return;
if (millis() < musicNoteEnd) return;
if (musicNoteIdx >= NUM_NOTES) musicNoteIdx = 0;
const Note& n = introMusic[musicNoteIdx++];
if (n.freq==0) stopTone(); else playTone(n.freq);
musicNoteEnd = millis() + n.dur;
}
// ── SFX ───────────────────────────────────────────────────────────────────────
void sfxShoot() { beep(1200,30); }
void sfxMissile() { beep(500,80); }
void sfxAlienDie() { beep(600,60); triggerLedFade(); }
void sfxPlayerHit() { beep(200,200); }
void sfxBossHit() { beep(400,80); }
void sfxBossDie() {
triggerLedFade();
for (int f=800;f>100;f-=80) { playTone(f); delay(30); }
stopTone();
}
void sfxWave2Start() {
for (int f=200;f<900;f+=60) { playTone(f); delay(25); }
stopTone();
}
// ── Game States ───────────────────────────────────────────────────────────────
enum State { INTRO, LORE, PLAYING, GAMEOVER };
State gameState = INTRO;
unsigned long stateTimer = 0;
int score = 0;
int lives = 3;
int wave = 1;
bool wave2Started = false;
// ── Starfield ─────────────────────────────────────────────────────────────────
#define NUM_STARS 60
struct Star { int x,y,speed; char color; };
Star stars[NUM_STARS];
int starWarp = 1;
// ── Debris ────────────────────────────────────────────────────────────────────
#define NUM_DEBRIS 5
struct Debris { int x,y,r,speed; char color; };
Debris debris[NUM_DEBRIS];
unsigned long lastDebrisMove = 0;
const int debrisMoveDelay = 50;
// ── Player ────────────────────────────────────────────────────────────────────
int pX = 300, pOldX = 300;
int pY = 390, pOldY = 390;
const int pW = 40, pH = 24, pSpeed = 5;
const int pYMin = 50;
const int pYMax = SH - pH - 8;
// ── Player Bullet ─────────────────────────────────────────────────────────────
int bX=0, bY=0, bOldY=0;
bool bActive=false;
const int bSpeed=12, bW=4, bH=12;
// Debounce fire button
bool lastFireBtn = false;
bool lastMissBtn = false;
// ── Missile ───────────────────────────────────────────────────────────────────
#define MISSILE_MAX 5
int missileAmmo = MISSILE_MAX;
int mX=0, mY=0, mOldY=0;
bool mActive=false;
const int mSpeed=6, mW=12, mH=20;
// ── Alien Army ────────────────────────────────────────────────────────────────
#define MAX_ROWS 5
#define MAX_COLS 10
bool alienAlive[MAX_ROWS][MAX_COLS];
int alienRows=4, alienCols=8;
int alienX=50, alienY=50, alienOldX=50, alienOldY=50;
const int aW=24, aH=16, aPad=14;
int alienDir=1, alienSpeed=4;
unsigned long lastAlienMove=0;
int alienMoveDelay=40;
// ── Alien Bullets ─────────────────────────────────────────────────────────────
#define MAX_ABULLETS 5
struct AlienBullet { int x,y,oldY; bool active; };
AlienBullet abullets[MAX_ABULLETS];
unsigned long lastAlienFire=0;
int alienFireInterval=9999;
// ── Boss ──────────────────────────────────────────────────────────────────────
bool bossActive=false;
int bossX=SW/2, bossY=130, bossOldX=SW/2, bossOldY=130;
int bossR=24;
#define BOSS_HALF 56
int bossHP=10, bossMaxHP=10;
int bossHDir=1, bossVDir=1;
int bossHSpeed=3, bossVSpeed=2;
const int bossYMin=60, bossYMax=230;
unsigned long lastBossMove=0;
int bossMoveDelay=25;
unsigned long lastVFlip=0;
int vFlipInterval=1200;
int bbX=0, bbY=0, bbOldY=0;
bool bbActive=false;
int bbSpeed=7;
const int bbW=6, bbH=14;
int bossFireChance=35;
int pinAnim=0;
unsigned long lastPinAnim=0;
// ── Lore ──────────────────────────────────────────────────────────────────────
char* loreLines[] = {
"YEAR 2187.",
"",
"THEY CAME FROM THE SIGNAL.",
"",
"BEINGS BORN FROM CORRUPTED DATA,",
"SHAPED LIKE THE VERY SCREENS",
"THEY INVADED THROUGH.",
"",
"THE VGA ARMADA WIPED OUT",
"EARTH'S DEFENCES IN 72 HOURS.",
"",
"ONE PILOT. ONE SHIP.",
"ONE LAST CHANCE.",
"",
"YOU ARE HUMANITY'S FINAL PIXEL."
};
const int NUM_LORE_LINES=15;
int loreCurLine=0, loreCurChar=0;
unsigned long lastCharTime=0;
bool loreAllDone=false;
unsigned long loreDoneTime=0;
uint loreBeepFreqs[]={900,950,880,960,840,1000,870,930,810,980};
// ── Helper: Draw Dual-Color Text ──────────────────────────────────────────────
void drawShadowText(int x, int y, const char* text, int size, char shadowCol, char textCol) {
setTextSize(size);
setTextColor(shadowCol);
int offset = (size > 2) ? size / 2 : 1;
setTextCursor(x + offset, y + offset);
writeString((char*)text);
setTextColor(textCol);
setTextCursor(x, y);
writeString((char*)text);
}
// ── HUD ───────────────────────────────────────────────────────────────────────
void drawHeart(int cx, int cy, char color) {
const char h[5][5]={{0,1,0,1,0},{1,1,1,1,1},{1,1,1,1,1},{0,1,1,1,0},{0,0,1,0,0}};
for (int r=0;r<5;r++) for (int c=0;c<5;c++)
if (h[r][c]) fillRect(cx+c*2, cy+r*2, 2, 2, color);
}
void drawMissileAmmoBar(bool force) {
static int lastAmmo = -1;
if (!force && missileAmmo == lastAmmo) return;
lastAmmo = missileAmmo;
int barW=14, barH=8, gap=3;
int totalW = MISSILE_MAX*(barW+gap)-gap;
int startX = SW-102;
int startY = 32;
for (int i=0;i<MISSILE_MAX;i++) {
int bx = startX + i*(barW+gap);
char col = (i < missileAmmo) ? GREEN : BLACK;
fillRect(bx, startY, barW, barH, col);
drawRect(bx, startY, barW, barH, WHITE);
}
}
void drawHUD(bool force) {
static int lastScore=-1, lastLives=-1, lastBossHP=-1;
static bool lastBossActive=false;
if (force||score!=lastScore) {
char buf[20]; sprintf(buf,"SCORE %05d",score);
fillRect(8,8,200,18,BLACK);
setTextCursor(8,8); setTextColor(WHITE); setTextSize(2);
writeString(buf);
lastScore=score;
}
if (force||lives!=lastLives) {
fillRect(SW-102,8,94,18,BLACK);
for (int i=0;i<lives;i++) drawHeart((SW-94)+i*20,10,RED);
lastLives=lives;
}
drawMissileAmmoBar(force);
if (force) {
char wbuf[10]; sprintf(wbuf,"W%d",wave);
fillRect(SW/2-14,8,28,16,BLACK);
setTextCursor(SW/2-14,8); setTextColor(YELLOW); setTextSize(2);
writeString(wbuf);
}
if (bossActive) {
if (force||bossHP!=lastBossHP||!lastBossActive) {
fillRect(SW/2-65,28,130,10,BLACK);
setTextSize(1); setTextColor(RED);
setTextCursor(SW/2-48,28);
writeString((char*)(wave==1?"VGA OVERLORD":"ARCH-OVERLORD"));
fillRect(SW/2-52,39,104,12,BLACK);
drawRect(SW/2-50,40,100,8,WHITE);
if (bossHP>0) fillRect(SW/2-49,41,(bossHP*100)/bossMaxHP,6,RED);
lastBossHP=bossHP; lastBossActive=true;
}
} else if (lastBossActive||force) {
fillRect(SW/2-65,28,130,26,BLACK);
lastBossHP=-1; lastBossActive=false;
}
}
// ── Sprites ───────────────────────────────────────────────────────────────────
void drawShip(int x, int y, bool erase) {
if (erase) { fillRect(x,y,pW,pH+8,BLACK); return; }
fillRect(x+12,y+8,16,16,CYAN);
fillRect(x+16,y, 8, 8,RED);
fillRect(x, y+12,8,12,WHITE);
fillRect(x, y+6, 8, 6,RED);
fillRect(x+32,y+12,8,12,WHITE);
fillRect(x+32,y+6, 8, 6,RED);
if (millis()%200<100) fillRect(x+16,y+24, 8,8,YELLOW);
else fillRect(x+14,y+24,12,6,RED);
}
void drawAlienSprite(int x, int y, bool erase, int row) {
if (erase) { fillRect(x,y,aW,aH,BLACK); return; }
const char w1c[4]={CYAN,MAGENTA,GREEN,YELLOW};
const char w2c[5]={RED,YELLOW,MAGENTA,CYAN,WHITE};
char c=(wave==1)?w1c[row%4]:w2c[row%5];
const unsigned short spr[8]={
0b01111111110,0b11000000011,0b10111111101,0b10101010101,
0b10111111101,0b11000000011,0b01111111110,0b00011011000
};
for (int r=0;r<8;r++) {
unsigned short line=spr[r];
for (int col=0;col<11;col++)
if (line&(1<<(10-col)))
fillRect(x+1+col*2,y+r*2,2,2,c);
}
}
void drawAliens(bool erase) {
for (int r=0;r<alienRows;r++)
for (int c=0;c<alienCols;c++)
if (alienAlive[r][c]) {
int x=(erase?alienOldX:alienX)+c*(aW+aPad);
int y=(erase?alienOldY:alienY)+r*(aH+aPad);
drawAlienSprite(x,y,erase,r);
}
}
// ── VGA DB-15 Boss ────────────────────────────────────────────────────────────
void drawVGABoss(int cx, int cy, bool erase) {
int bW = bossR*2 + 12;
int bH = bossR + 20;
int bx = cx - bW/2;
int by = cy - bH/2;
if (erase) {
fillRect(cx-BOSS_HALF, cy-BOSS_HALF, BOSS_HALF*2, BOSS_HALF*2, BLACK);
return;
}
char bodyCol = (wave==1) ? CYAN : MAGENTA;
fillRect(bx, by, bW, bH, bodyCol);
int notch=4;
fillRect(bx, by, notch, notch, BLACK);
fillRect(bx+bW-notch, by, notch, notch, BLACK);
fillRect(bx, by+bH-notch, notch, notch, BLACK);
fillRect(bx+bW-notch, by+bH-notch, notch, notch, BLACK);
char outlineCol = (wave==1) ? WHITE : YELLOW;
drawRect(bx+notch, by, bW-notch*2, bH, outlineCol);
drawRect(bx, by+notch, bW, bH-notch*2, outlineCol);
int pinRows = 3, pinsPerRow = 5;
int pinR = 2;
char pinCol = GREEN;
for (int row=0; row<pinRows; row++) {
int py = by + (bH*(row+1))/(pinRows+1);
for (int p=0; p<pinsPerRow; p++) {
int px = bx + (bW*(p+1))/(pinsPerRow+1);
fillCircle(px, py, pinR, BLACK);
drawCircle(px, py, pinR, pinCol);
}
}
char pinNumStr[3];
sprintf(pinNumStr, "%02d", pinAnim+1);
char numCol = (wave==1) ? WHITE : RED;
drawChar(cx-6, cy-4, pinNumStr[0], numCol, bodyCol, 1);
drawChar(cx, cy-4, pinNumStr[1], numCol, bodyCol, 1);
if (wave==2) {
drawRect(bx-4, by-4, bW+8, bH+8, RED);
drawRect(bx-6, by-6, bW+12, bH+12, YELLOW);
}
}
void drawExplosion(int x, int y) {
drawCircle(x+aW/2,y+aH/2, 8,YELLOW);
drawCircle(x+aW/2,y+aH/2,14,RED);
delay(20);
fillCircle(x+aW/2,y+aH/2,15,BLACK);
}
void drawBigExplosion(int x, int y) {
fillCircle(x,y,20,YELLOW); delay(30);
fillCircle(x,y,30,RED); delay(30);
fillCircle(x,y,32,BLACK);
}
// ── Missile draw ──────────────────────────────────────────────────────────────
void drawMissile(int x, int y, bool erase) {
if (erase) {
fillRect(x-2, y-4, mW+4, mH+12, BLACK);
return;
}
fillRect(x, y, mW, mH, YELLOW);
fillRect(x+3, y-4, mW-6, 4, RED); // nose
fillRect(x-2, y+mH, 4, 4, WHITE); // left fin
fillRect(x+mW-2, y+mH, 4, 4, WHITE); // right fin
if (millis()%150<75) fillRect(x+2,y+mH+4,mW-4,4,YELLOW);
else fillRect(x+2,y+mH+4,mW-4,4,RED);
}
// ── Background ────────────────────────────────────────────────────────────────
void updateStars() {
for (int i=0;i<NUM_STARS;i++) {
drawPixel(stars[i].x,stars[i].y,BLACK);
stars[i].y+=stars[i].speed*starWarp;
if (stars[i].y>=SH) { stars[i].y=50; stars[i].x=random(0,SW); }
drawPixel(stars[i].x,stars[i].y,(starWarp>1)?WHITE:stars[i].color);
}
}
void updateDebris() {
if (millis()-lastDebrisMove<debrisMoveDelay) return;
lastDebrisMove=millis();
for (int i=0;i<NUM_DEBRIS;i++) {
if (debris[i].y>48-debris[i].r) fillCircle(debris[i].x,debris[i].y,debris[i].r,BLACK);
debris[i].y+=debris[i].speed;
if (debris[i].y>SH+debris[i].r) {
debris[i].y=random(-200,-40); debris[i].x=random(40,SW-40);
debris[i].r=random(2,5); debris[i].speed=random(1,3);
}
if (debris[i].y>48-debris[i].r) fillCircle(debris[i].x,debris[i].y,debris[i].r,debris[i].color);
}
}
// ── Alien bullets ─────────────────────────────────────────────────────────────
void clearAlienBullets() { for (int i=0;i<MAX_ABULLETS;i++) abullets[i].active=false; }
void updateAlienBullets() {
if (wave<2) return;
if (millis()-lastAlienFire>(unsigned long)alienFireInterval) {
lastAlienFire=millis();
int col=random(0,alienCols), firingRow=-1;
for (int r=alienRows-1;r>=0;r--) if (alienAlive[r][col]) { firingRow=r; break; }
if (firingRow>=0) {
for (int i=0;i<MAX_ABULLETS;i++) {
if (!abullets[i].active) {
abullets[i].active=true;
abullets[i].x=alienX+col*(aW+aPad)+aW/2-2;
abullets[i].y=alienY+firingRow*(aH+aPad)+aH;
abullets[i].oldY=abullets[i].y;
break;
}
}
}
}
for (int i=0;i<MAX_ABULLETS;i++) {
if (!abullets[i].active) continue;
fillRect(abullets[i].x,abullets[i].oldY,4,10,BLACK);
abullets[i].y+=5; abullets[i].oldY=abullets[i].y;
if (abullets[i].y>SH) { abullets[i].active=false; }
else if (abullets[i].x+4>pX&&abullets[i].x<pX+pW&&
abullets[i].y+10>pY&&abullets[i].y<pY+pH) {
fillRect(abullets[i].x,abullets[i].y,4,10,BLACK);
abullets[i].active=false;
lives--; sfxPlayerHit(); drawHUD(true);
drawBigExplosion(pX+pW/2,pY+pH/2); checkLives();
} else {
fillRect(abullets[i].x,abullets[i].y,4,10,MAGENTA);
}
}
}
// ── Game Logic ────────────────────────────────────────────────────────────────
void checkLives() {
if (lives<=0) {
stopIntroMusic();
drawGameOverScreen();
gameState=GAMEOVER; stateTimer=millis();
}
}
void updateAliens() {
if (bossActive) return;
if (millis()-lastAlienMove<(unsigned long)alienMoveDelay) return;
lastAlienMove=millis();
alienOldX=alienX; alienOldY=alienY;
alienX+=alienDir*alienSpeed;
bool hitEdge=false;
for (int r=0;r<alienRows;r++)
for (int c=0;c<alienCols;c++)
if (alienAlive[r][c]) {
int ax=alienX+c*(aW+aPad);
if (ax<=10||ax+aW>=SW-10) hitEdge=true;
}
if (hitEdge) { alienDir*=-1; alienX+=alienDir*alienSpeed; alienY+=14; }
drawAliens(true); drawAliens(false);
}
void updateBoss() {
if (!bossActive) return;
if (millis()-lastPinAnim>150) {
lastPinAnim=millis();
pinAnim=(pinAnim+1)%15;
}
if (millis()-lastBossMove>(unsigned long)bossMoveDelay) {
lastBossMove=millis();
if (millis()-lastVFlip>(unsigned long)vFlipInterval) {
lastVFlip=millis(); bossVDir=(random(0,2)==0)?1:-1;
vFlipInterval=700+random(0,700);
}
bossOldX=bossX; bossOldY=bossY;
bossX+=bossHDir*bossHSpeed; bossY+=bossVDir*bossVSpeed;
if (bossX-bossR<20||bossX+bossR>SW-20) { bossHDir*=-1; bossX+=bossHDir*bossHSpeed*2; }
if (bossY<bossYMin) { bossY=bossYMin; bossVDir=1; }
if (bossY>bossYMax) { bossY=bossYMax; bossVDir=-1; }
drawVGABoss(bossOldX,bossOldY,true);
drawVGABoss(bossX,bossY,false);
}
if (!bbActive&&random(0,bossFireChance)==1) {
bbActive=true; bbX=bossX-bbW/2;
bbY=bossY+(bossR+20)/2+12; bbOldY=bbY;
}
if (bbActive) {
fillRect(bbX,bbOldY,bbW,bbH,BLACK);
bbY+=bbSpeed; bbOldY=bbY;
if (bbY>SH) { bbActive=false; }
else if (bbX+bbW>pX&&bbX<pX+pW&&bbY+bbH>pY&&bbY<pY+pH) {
fillRect(bbX,bbY,bbW,bbH,BLACK); bbActive=false;
lives--; sfxPlayerHit(); drawHUD(true);
drawBigExplosion(pX+pW/2,pY+pH/2); checkLives();
} else {
fillRect(bbX,bbY,bbW,bbH,wave==1?RED:YELLOW);
}
}
}
// ── Player Collisions ─────────────────────────────────────────────────────────
void checkPlayerCollisions() {
bool crashed = false;
if (bossActive) {
int bW2 = bossR*2 + 12;
int bH2 = bossR + 20;
int bx = bossX - bW2/2;
int by = bossY - bH2/2;
if (pX < bx + bW2 && pX + pW > bx && pY < by + bH2 && pY + pH > by) {
crashed = true;
}
} else {
for (int r = 0; r < alienRows; r++) {
for (int c = 0; c < alienCols; c++) {
if (alienAlive[r][c]) {
int ax = alienX + c*(aW+aPad);
int ay = alienY + r*(aH+aPad);
if (pX < ax + aW && pX + pW > ax && pY < ay + aH && pY + pH > ay) {
crashed = true;
alienAlive[r][c] = false;
fillRect(ax, ay, aW, aH, BLACK);
}
}
}
}
}
if (crashed) {
lives = 0; // Instant game over
sfxPlayerHit();
drawBigExplosion(pX + pW/2, pY + pH/2);
drawHUD(true);
checkLives();
}
}
// ── Bullet (normal) ───────────────────────────────────────────────────────────
void updateBullet() {
if (!bActive) return;
fillRect(bX,bOldY,bW,bH,BLACK);
if (bY<50) { bActive=false; return; }
bY-=bSpeed;
bool hit=false;
if (bossActive) {
int bW2=bossR*2+12, bH2=bossR+20;
if (bX+bW>bossX-bW2/2&&bX<bossX+bW2/2&&bY+bH>bossY-bH2/2&&bY<bossY+bH2/2) {
bActive=false; hit=true; bossHP--;
sfxBossHit(); drawHUD(true);
drawExplosion(bossX-10,bossY+10);
if (bossHP<=0) {
bossActive=false; score+=500;
drawVGABoss(bossX,bossY,true);
sfxBossDie(); drawBigExplosion(bossX,bossY); drawHUD(true);
}
}
} else {
for (int r=0;r<alienRows&&!hit;r++)
for (int c=0;c<alienCols&&!hit;c++)
if (alienAlive[r][c]) {
int ax=alienX+c*(aW+aPad), ay=alienY+r*(aH+aPad);
bool hitNow =(bX<ax+aW&&bX+bW>ax&&bY<ay+aH&&bY+bH>ay);
bool hitPrev=(bX<ax+aW&&bX+bW>ax&&bY+bSpeed<ay+aH&&bY+bSpeed+bH>ay);
if (hitNow||hitPrev) {
alienAlive[r][c]=false; hit=true; bActive=false;
fillRect(ax,ay,aW,aH,BLACK);
drawExplosion(ax,ay); sfxAlienDie(); score+=15;
}
}
}
if (!hit) { fillRect(bX,bY,bW,bH,YELLOW); bOldY=bY; }
}
// ── Missile update ────────────────────────────────────────────────────────────
void updateMissile() {
if (!mActive) return;
drawMissile(mX,mOldY,true);
if (mY<50) { mActive=false; return; }
mY-=mSpeed; mOldY=mY;
bool hit=false;
if (bossActive) {
int bW2=bossR*2+12, bH2=bossR+20;
if (mX+mW>bossX-bW2/2&&mX<bossX+bW2/2&&mY+mH>bossY-bH2/2&&mY<bossY+bH2/2) {
mActive=false; hit=true;
bossHP-=3; if (bossHP<0) bossHP=0;
sfxBossHit(); drawHUD(true);
drawExplosion(bossX-10,bossY+10);
if (bossHP<=0) {
bossActive=false; score+=500;
drawVGABoss(bossX,bossY,true);
sfxBossDie(); drawBigExplosion(bossX,bossY); drawHUD(true);
}
}
} else {
for (int r=0;r<alienRows;r++)
for (int c=0;c<alienCols;c++)
if (alienAlive[r][c]) {
int ax=alienX+c*(aW+aPad), ay=alienY+r*(aH+aPad);
if (mX<ax+aW&&mX+mW>ax&&mY<ay+aH&&mY+mH>ay) {
alienAlive[r][c]=false;
fillRect(ax,ay,aW,aH,BLACK);
drawExplosion(ax,ay); sfxAlienDie(); score+=15;
hit=true;
}
}
hit=false;
}
if (!hit) drawMissile(mX,mY,false);
else drawMissile(mX,mY,false);
}
// ── Player (button-controlled) ────────────────────────────────────────────────
void updatePlayer() {
pOldX=pX; pOldY=pY;
if (btnPressed(BTN_LEFT)) pX-=pSpeed;
if (btnPressed(BTN_RIGHT)) pX+=pSpeed;
if (btnPressed(BTN_UP)) pY-=pSpeed;
if (btnPressed(BTN_DOWN)) pY+=pSpeed;
if (pX<10) pX=10;
if (pX+pW>SW-10) pX=SW-pW-10;
if (pY<pYMin) pY=pYMin;
if (pY>pYMax) pY=pYMax;
if (pOldX!=pX || pOldY!=pY) drawShip(pOldX,pOldY,true);
drawShip(pX,pY,false);
bool fireNow = btnPressed(BTN_FIRE);
if (fireNow && !lastFireBtn && !bActive) {
bActive=true;
bX=pX+pW/2-bW/2; bY=pY-bH; bOldY=bY;
sfxShoot();
}
lastFireBtn=fireNow;
bool missNow = btnPressed(BTN_MISSILE);
if (missNow && !lastMissBtn && !mActive && missileAmmo>0) {
mActive=true; missileAmmo--;
mX=pX+pW/2-mW/2; mY=pY-mH-4; mOldY=mY;
sfxMissile(); drawHUD(true);
}
lastMissBtn=missNow;
}
// ── Wave / game flow ──────────────────────────────────────────────────────────
void startWave(int w) {
wave=w;
alienRows=(w==1)?4:5; alienCols=(w==1)?8:10;
alienX=50; alienOldX=50; alienY=50; alienOldY=50; alienDir=1;
alienSpeed=(w==1)?4:5; alienMoveDelay=(w==1)?40:28;
alienFireInterval=(w==1)?9999:800;
for (int r=0;r<MAX_ROWS;r++)
for (int c=0;c<MAX_COLS;c++)
alienAlive[r][c]=(r<alienRows&&c<alienCols);
clearAlienBullets();
bossHP=(w==1)?10:20; bossMaxHP=bossHP;
bossR=(w==1)?24:32;
bossHSpeed=(w==1)?3:5; bossVSpeed=(w==1)?2:3;
bossMoveDelay=(w==1)?25:18; bossFireChance=(w==1)?35:20;
bbSpeed=(w==1)?7:10; bossActive=false;
bossX=SW/2; bossOldX=bossX; bossY=bossYMin+20; bossOldY=bossY;
bossHDir=1; bossVDir=1; bbActive=false;
mActive=false; lastVFlip=millis(); vFlipInterval=1200;
missileAmmo=MISSILE_MAX;
}
void checkGameState() {
if (!bossActive&&bossHP<=0) {
if (wave==1&&!wave2Started) {
wave2Started=true;
delay(500); clearScreen();
setTextSize(4); setTextColor(RED);
setTextCursor(100,180); writeString((char*)"WAVE 2");
setTextSize(2); setTextColor(YELLOW);
setTextCursor(80,250); writeString((char*)"THE ARCH-OVERLORD AWAKENS");
sfxWave2Start(); delay(2500);
clearScreen(); startWave(2); drawHUD(true); return;
}
}
if (!bossActive&&bossHP>0) {
bool anyAlive=false;
for (int r=0;r<alienRows;r++)
for (int c=0;c<alienCols;c++)
if (alienAlive[r][c]) anyAlive=true;
if (!anyAlive) {
bossActive=true;
bossX=SW/2; bossOldX=bossX; bossY=bossYMin+20; bossOldY=bossY;
bossHDir=1; bossVDir=1; lastVFlip=millis(); drawHUD(true);
}
}
}
// ── Screens ───────────────────────────────────────────────────────────────────
void drawIntroScreen() {
clearScreen();
for (int i=0;i<NUM_STARS;i++) drawPixel(stars[i].x,stars[i].y,stars[i].color);
setTextSize(8); setTextColor(YELLOW);
setTextCursor(224,30); writeString((char*)"PICO");
setTextCursor(248,114); writeString((char*)"VGA");
setTextCursor(152,198); writeString((char*)"BLASTER");
setTextSize(2); setTextColor(WHITE);
setTextCursor(190,360); writeString((char*)"DEFEND HUMANITY");
setTextSize(2); setTextColor(GREEN);
setTextCursor(148,420); writeString((char*)"INCOMING TRANSMISSION...");
}
void drawLoreBackground() {
clearScreen();
for (int i=0;i<NUM_STARS;i++) drawPixel(stars[i].x,stars[i].y,stars[i].color);
setTextSize(2); setTextColor(YELLOW);
setTextCursor(SW/2-96,14); writeString((char*)"// INTEL UPLINK //");
setTextSize(1); setTextColor(CYAN);
setTextCursor(20,SH-16); writeString((char*)"END TRANSMISSION -- LAUNCHING MISSION");
}
bool tickLoreTypewriter() {
if (loreCurLine>=NUM_LORE_LINES) return true;
if (millis()-lastCharTime<38) return false;
lastCharTime=millis();
char* line=loreLines[loreCurLine];
int lineLen=strlen(line);
if (lineLen==0) { loreCurLine++; loreCurChar=0; return false; }
if (loreCurChar<lineLen) {
int ty=42+loreCurLine*24;
setTextSize(2); setTextColor(GREEN);
setTextCursor(30+loreCurChar*12,ty);
char buf[2]={line[loreCurChar],'\0'};
writeString(buf);
beep(loreBeepFreqs[loreCurChar%10],18);
loreCurChar++;
} else { loreCurLine++; loreCurChar=0; }
return false;
}
// ── Epic Game Over Screen ────────────────────────────────────────────────
void drawGameOverScreen() {
clearScreen();
for (int i=0;i<NUM_STARS;i++) drawPixel(stars[i].x,stars[i].y,stars[i].color);
drawShadowText(104, 80, "GAME OVER", 8, RED, WHITE);
drawShadowText(224, 180, "YOU LOST", 4, RED, MAGENTA);
drawShadowText(200, 250, "HUMANITY HAS FALLEN.", 2, BLUE, CYAN);
drawShadowText(200, 290, "THE VGA ARMADA WINS.", 2, BLUE, WHITE);
char buf[30]; sprintf(buf,"FINAL SCORE: %05d",score);
int bx = SW/2 - (int)(strlen(buf)*9);
drawShadowText(bx, 350, buf, 3, RED, YELLOW);
drawShadowText(206, 410, "RESTARTING IN 5s...", 2, BLUE, GREEN);
for (int f=500;f>80;f-=40) { playTone(f); delay(40); }
stopTone();
}
void resetAllGameState() {
score=0; lives=3; wave2Started=false;
pX=300; pOldX=300; pY=390; pOldY=390; bActive=false; mActive=false;
missileAmmo=MISSILE_MAX;
startWave(1); lastAlienFire=0;
}
// ── Setup ─────────────────────────────────────────────────────────────────────
void setup() {
initVGA();
buzzerInit();
buttonsInit();
ws2812Init();
clearScreen();
for (int i=0;i<NUM_STARS;i++) {
stars[i].x=random(0,SW); stars[i].y=random(50,SH);
stars[i].speed=random(1,3);
stars[i].color=(random(0,3)==0)?CYAN:WHITE;
}
clearAlienBullets();
startWave(1);
starWarp=1;
drawIntroScreen();
startIntroMusic();
gameState=INTRO; stateTimer=millis();
}
// ── Main Loop ─────────────────────────────────────────────────────────────────
void loop() {
updateBuzzer();
updateLed();
if (gameState==INTRO) {
updateIntroMusic();
updateStars();
if (millis()-stateTimer>4000) {
stopIntroMusic();
loreCurLine=0; loreCurChar=0; loreAllDone=false;
lastCharTime=millis();
drawLoreBackground();
gameState=LORE; stateTimer=millis();
}
}
else if (gameState==LORE) {
updateStars();
bool done=tickLoreTypewriter();
if (done&&!loreAllDone) {
loreAllDone=true; loreDoneTime=millis();
setTextSize(2); setTextColor(WHITE);
setTextCursor(SW/2-110,SH-50);
writeString((char*)"GOOD LUCK, PILOT...");
beep(440,400);
}
if (loreAllDone&&millis()-loreDoneTime>2500) {
clearScreen();
for (int i=0;i<NUM_STARS;i++)
drawPixel(stars[i].x,stars[i].y,stars[i].color);
starWarp=1; drawHUD(true);
gameState=PLAYING;
}
}
else if (gameState==PLAYING) {
checkGameState();
updatePlayer();
updateBullet();
updateMissile();
updateAliens();
updateAlienBullets();
updateBoss();
checkPlayerCollisions();
drawHUD(false);
}
else if (gameState==GAMEOVER) {
if (millis()-stateTimer>5000) {
resetAllGameState();
starWarp=1;
for (int i=0;i<NUM_STARS;i++) {
stars[i].x=random(0,SW); stars[i].y=random(50,SH);
stars[i].speed=random(1,3);
stars[i].color=(random(0,3)==0)?CYAN:WHITE;
}
drawIntroScreen();
startIntroMusic();
gameState=INTRO; stateTimer=millis();
}
}
delay(16);
}The code is long, but the core functionality is super simple. Let me explain.
#include "vga_graphics.h"
#include "hardware/pwm.h"
#include "hardware/clocks.h"
#include "hardware/gpio.h"
#include "hardware/sync.h"We use the above libraries in our code. The VGA graphics library is used for generating VGA video output, while the hardware libraries are used for PWM audio, GPIO button input, clock handling, and synchronization.
#define SW 640
#define SH 480Here, we define the VGA screen resolution. Our setup runs at 640×480 resolution.
#define BTN_FIRE 0
#define BTN_MISSILE 2
#define BTN_LEFT 3
#define BTN_RIGHT 5
#define BTN_UP 1
#define BTN_DOWN 4Four buttons are used for directional movement, while the remaining two are used for firing bullets and missiles.
#define LED_PIN 6
#define NUM_LEDS 8We define the RGB LED strip used for lighting effects during gameplay. The LEDs glow whenever enemies are destroyed or special events occur.
enum State { INTRO, LORE, PLAYING, GAMEOVER };The game is divided into multiple states, including the intro screen, lore screen, gameplay, and game over screen.
char* loreLines[] = {
"YEAR 2187.",
"",
"THEY CAME FROM THE SIGNAL.",
"",
"BEINGS BORN FROM CORRUPTED DATA,",
"SHAPED LIKE THE VERY SCREENS",
"THEY INVADED THROUGH.",
"",
"THE VGA ARMADA WIPED OUT",
"EARTH'S DEFENCES IN 72 HOURS.",
"",
"ONE PILOT. ONE SHIP.",
"ONE LAST CHANCE.",
"",
"YOU ARE HUMANITY'S FINAL PIXEL."
};I also added a splash screen with a complete game lore sequence. Before gameplay starts, the game displays a cinematic intro story using animated typewriter-style text.
void drawIntroScreen()This function renders the main splash screen of the game with the “PICO VGA BLASTER” title and animated background stars.
void tickLoreTypewriter()This function creates the typewriter animation effect for the lore screen, where text appears character by character along with sound effects.
void buttonsInit()This function initializes all button GPIO pins and enables internal pull-up resistors so the Pico can detect button presses correctly.
void updatePlayer()This is the main player control function. It reads directional button inputs and updates the spaceship movement and attacks.
if (btnPressed(BTN_LEFT)) pX -= pSpeed;
if (btnPressed(BTN_RIGHT)) pX += pSpeed;This part handles the horizontal movement of the spaceship.
if (fireNow && !lastFireBtn && !bActive)This creates a bullet projectile whenever the fire button is pressed.
void drawHUD()This renders the game HUD, including score, lives, missile ammo, and boss health bar.
void drawGameOverScreen()This function displays the final game-over screen with the player’s score and restart animation.
3D DESIGNFor the design of this project, my goal was to create an enclosure that could house both the PICO VGA Board and the Button Board together. The VGA port and DC jack for power needed to remain visible from one end, so the VGA cable and power input could be accessed easily. I also wanted the device to feature large buttons that would be comfortable and easy to use.
The enclosure design itself was kept very minimal, but to improve the aesthetics, the directional buttons were designed as triangles rotated according to the direction of the D-pad. The Fire and Missile buttons were modeled in round and square shapes, respectively.
To further enhance the look, I added four greeble parts, O, Plus, and X-shaped elements, along with a separate part featuring the legendary Konami Code: “Up Up Down Down Left Right Left Right B A Start.”
My idea was to print the main enclosure in orange color and use black PLA for the rest of the parts, creating a dual-tone aesthetic for the final design.
ENCLOSURE DESIGNFor the enclosure, we modeled two parts: the housing and the lid. All the components, including the PICO VGA Board, Button Board, switches, and greeble parts, are housed inside the main housing.
The switch triangles were positioned inside the housing and are held in place once the Button PCB is mounted over them, keeping everything securely locked in position. When these switch triangles are pressed, they activate the switches on the Button Board, and the Pico detects the button press and performs the corresponding function.
The VGA Board is also mounted inside the housing on dedicated screw bosses, allowing it to be fixed in place using M2 screws. Similarly, screw bosses were added for securing the Button Board using M2 screws as well.
From the back side, a separate lid part was modeled to close the housing. We added four mounting holes, two on each side, which use four M2 screws to securely fasten the housing and lid together.
GREEBLESSome greeble parts were also modeled to improve the aesthetics of the design. I designed regular-shaped O, Plus, and X symbols, which were added to the front face of the housing. I even created dedicated slots where all three parts fit perfectly into position.
Next, as an easter egg for gamers, I added the legendary Konami Code. I modeled a separate part featuring the physical shapes of Up, Down, Left, Right, A, B, and Start, and positioned it slightly above the square button.
3D PRINTED PARTSFor the 3D prints, the housing was the only part printed in Orange Hyper PLA using a 0.4mm nozzle, 0.16mm layer height, and 25% infill. I printed the part face down, which allowed me to avoid using any support material.
Using the same print settings but with Black Hyper PLA, I first printed the lid part separately and then printed all the greeble parts together.
The switch parts were printed separately, as they required support material, for which I used tree supports.
ENCLOSURE ASSEMBLY- The enclosure assembly begins by placing all the switches into position from inside the housing.
- Over the switches, we position the Button Board by aligning its mounting holes with the screw bosses of the housing.
- Similarly, we also place the VGA Board onto its dedicated screw bosses.
- M2 screws were used throughout the assembly, two for securing the Button Board and three for securing the VGA Board in place.
- Finally, we place the lid from the bottom side and use four M2 screws to permanently secure the lid to the housing.
- We begin the greeble assembly by applying super glue to the mounting spots, then placing the O-shaped part into position.
- Similarly, we followed the same process for the Plus and X greeble parts, first applying super glue to their mounting points and then positioning each part.
- Finally, we added the Konami Code part by applying super glue to the back side of the piece and positioning it slightly above the square switch.
Here’s the end result of this build: the PICO VGA BLASTER, a retro-styled console that runs my custom-made game.
To use this console, we connect it to a VGA monitor. In this case, we are using a regular LED monitor that supports VGA connectivity, and power the setup using a 12V power adapter connected through the DC jack.
The console runs my own version of Space Invaders, where the enemies are designed to look like VGA ports. Using the D-pad, we can control our spacecraft, and there are two attack options: a normal fire mode and a missile launcher. After defeating one fleet of enemies, a boss fight begins, followed by another fleet where the VGA enemies can now fire back at the player. After that comes a second boss fight, which is twice as difficult. If all three players' lives are lost, the game-over sequence is triggered.
I created this project for an event where I will be showcasing the setup on a large display so people can try the game themselves.
For Version 2 of this project, I would like to add controller support, make the console smaller, and possibly even add multiplayer support, and not to forget, improved sound functionality as well.
Special thanks for making it this far, and I’ll be back with another new project very soon.










_t9PF3orMPd.png?auto=compress%2Cformat&w=40&h=40&fit=fillmax&bg=fff&dpr=2)










Comments