Build a retro space shooter game on the ESP32 that runs on a 2-inch TFT display. This is an adaptation of the classic Space Impact arcade game, featuring player ship controls, enemy waves, power-ups, and score tracking stored in the device's memory.
This guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions. The firmware is pre-loaded with game logic, sprite rendering, and button input handling. Connect the display and push button, verify connections before powering on, and start playing using either physical buttons or the included virtual on-screen controls.
Wiring diagram
Gather all the parts
Assemble it in 4 steps
1. Conectar a tela com a alimentação desligada
VCC e BL para 3V3; GND para GND. MOSI para GPIO19 (imagem), SCK para GPIO21 (relógio), CS para GPIO23 (seleção), DC para GPIO18 (comando ou imagem), RST para GPIO22 (reinÃcio). MISO e SDCS ficam desconectados.
- Use somente sinais de 3,3 V. Confirme que sua entrada BL aceita ligação direta a 3V3; não alimente um LED de backlight nu sem limitação de corrente.
2. Conectar os botões
Cada botão fecha seu GPIO para GND ao apertar: tiro GPIO4; cima GPIO25; baixo GPIO26; esquerda GPIO27; direita GPIO32; pausa/iniciar GPIO13. Especial/mÃssil GPIO33 lança um mÃssil por pressionamento (cinco por partida). Os resistores internos mantêm os sinais estáveis.
- Em botões de quatro pernas, use contatos que só ficam conectados quando apertados.
- Segure tiro para disparos repetidos. Pausa inicia, pausa e continua; tiro reinicia após derrota.
3. Conferir antes de alimentar
Confira todos os fios e coloque a tela na horizontal. A imagem ocupa 320 × 225 pixels, com pequenas bordas pretas, preservando a proporção do repositório. Alimente o ESP32 por USB.
4. Usar o controle virtual
No celular ou computador, conecte à rede Wi-Fi SpaceImpact com a senha SpacePlay2026. Mantenha a conexão mesmo se aparecer o aviso de sem internet. Abra http://192.168.4.1 no navegador. A imagem fica no LCD e os botões fÃsicos continuam funcionando. Segure os botões virtuais; é possÃvel mover e atirar ao mesmo tempo. Não depende de roteador nem internet.
- Use apenas um jogador conectado por vez. A distância depende do alcance Wi-Fi.
- Se o controle perder contato, os botões virtuais são soltos automaticamente. Pode haver atraso pelo Wi-Fi; a lógica e a velocidade do jogo foram mantidas.
- A senha está no firmware; use esta rede somente para jogar, sem expor o controle à internet.
Review all connections
1. Connections between "lcd" and "ESP32"
2. Connections between "fire" and "ESP32"
3. Connections between "up" and "ESP32"
4. Connections between "down" and "ESP32"
5. Connections between "left" and "ESP32"
6. Connections between "right" and "ESP32"
7. Connections between "special" and "ESP32"
8. Connections between "pause" and "ESP32"
Deploy the firmware
// ESP32 adaptation of shahfarhadreza/space-impact (GPL-3.0).
// Original game.c retained alongside this file. Windows rendering replaced.
#include <Arduino.h>
#include <TFT_eSPI.h>
#include <math.h>
#include <Preferences.h>
#include "assets.h"
#include "web_control.h"
TFT_eSPI lcd;
TFT_eSprite frame(&lcd);
uint16_t* stripPixels=nullptr;
Preferences records;
uint32_t bestScore=0;
bool recordsReady=false;
// Render the same RGB565 image in strips: no palette or resolution loss.
constexpr int STRIP_HEIGHT=15;
int stripY=0;
void rect(int x,int y,int w,int h,uint32_t c){frame.fillRect(x,y-stripY,w,h,c);}
void text(const char* s,int x,int y){frame.drawString(s,x,y-stripY);}
constexpr int FIRE=4, UP=25, DOWN=26, LEFT=27, RIGHT=32, SPECIAL=33, PAUSE=13;
constexpr float SCALE=0.3125f;
constexpr float GAME_SPEED=1.155f; // Another 10% faster than 1.05; same graphics and frame cadence.
constexpr uint16_t GREEN=((127>>3)<<11)|((184>>2)<<5)|(145>>3);
struct Bullet {float x,y,speed;};
struct Enemy {float x,y,spawn,speed,time,range,cooldown,bs,target;int type,life;bool targeting;};
Enemy enemies[100]; Bullet shots[100], hostile[100];
Bullet rockets[5]; int nr=0; // Fixed storage: no new heap allocations.
int ne=0,ns=0,nh=0,lives=3,score=0,missiles=5,state=0;
void saveRecord(){
if((uint32_t)score>bestScore){bestScore=score;if(recordsReady && records.putUInt("best",bestScore)!=sizeof(uint32_t))Serial.println("Falha ao salvar recorde");}
}
float px=100,py=260,shield=100,elapsed=0,spawnTime=0,spawnMax=100,bg1=0,bg2=0;
const int widths[]={28,22,30,30}, heights[]={22,22,20,20};
const Image* enemyImages[]={&enemy_basic,&enemy_spaceship,&enemy_rocket,&enemy_scorpion};
float rnd(float a,float b){return a+(b-a)*(float)random(0,100000)/100000.0f;}
template<class T> void removeAt(T* a,int &n,int i){for(int j=i;j<n-1;j++)a[j]=a[j+1];--n;}
bool inside(float x,float y,float a,float b,int w,int h){return x>=a&&x<=a+w&&y>=b&&y<=b+h;}
void damage(){if(shield<=0){--lives;shield=100;if(lives<=0){state=3;saveRecord();}}}
void resetGame(){ne=ns=nh=nr=0;lives=3;score=0;missiles=5;px=100;py=260;shield=100;elapsed=spawnTime=bg1=bg2=0;spawnMax=100;state=1;}
void shoot(){if(ns<100)shots[ns++]={px+30,py+12,1.2f};}
void launchMissile(){if(missiles>0 && nr<5){rockets[nr++]={px+30,py+12,2.4f};--missiles;}}
void spawnEnemy(){if(ne>=100)return;int t=elapsed>6000?random(1,4):elapsed>4000?random(1,3):elapsed>2000?random(0,2):0;Enemy &e=enemies[ne++];e={};e.x=rnd(1024,1074);e.y=e.spawn=rnd(200,500);e.type=t;e.life=t==0?1:t==3?3:2;e.speed=t==0?rnd(.8,1.1):t==1?rnd(1,1.6):t==2?rnd(.6,.8):rnd(1.6,2);e.range=rnd(.18,.05);e.bs=rnd(1.5,1.8);e.cooldown=rnd(50,100);}
void update(float dt){
bg1-=.2f*dt;bg2-=.4f*dt;if(bg1<=-1024)bg1+=1024;if(bg2<=-1024)bg2+=1024;
px=constrain(px+((controlDown(LEFT)?-1:controlDown(RIGHT)?1:0)*1.1f*dt),1.0f,993.0f);
py=constrain(py+((controlDown(UP)?-1:controlDown(DOWN)?1:0)*1.1f*dt),31.0f,695.0f);
shield=max(0.0f,shield-dt*.5f);
for(int i=ns-1;i>=0;i--){auto &b=shots[i];bool hit=false;for(int j=ne-1;j>=0;j--){auto &e=enemies[j];if(inside(b.x,b.y,e.x,e.y,widths[e.type],heights[e.type])){const int points[]={10,20,30,50};score+=points[e.type];if(--e.life<=0)removeAt(enemies,ne,j);hit=true;break;}}if(hit||b.x>1024)removeAt(shots,ns,i);else b.x+=b.speed*dt;}
// Swept missile collision prevents skipping a small enemy between updates.
for(int i=nr-1;i>=0;i--){auto &r=rockets[i];float next=r.x+r.speed*dt;int target=-1;float nearest=2000;
for(int j=0;j<ne;j++){auto &e=enemies[j];if(r.x<=e.x+widths[e.type] && next+rocket.w/SCALE>=e.x && r.y-rocket.h/(2*SCALE)<e.y+heights[e.type] && r.y+rocket.h/(2*SCALE)>e.y && e.x<nearest){target=j;nearest=e.x;}}
if(target>=0){const int points[]={10,20,30,50};score+=points[enemies[target].type];removeAt(enemies,ne,target);removeAt(rockets,nr,i);}
else if(next>1024)removeAt(rockets,nr,i);else r.x=next;
}
for(int i=ne-1;i>=0;i--){auto &e=enemies[i];if(e.x<0){removeAt(enemies,ne,i);continue;}float old=e.y;e.x-=e.speed*dt;
if(e.type==1||e.type==2){e.y=e.spawn-120+240*e.range*(1-cosf(2*(22.0f/7)*e.time));e.time+=dt/200;if(e.time>=1){e.time=0;e.range=rnd(.18,.05);}}
else if(e.type==3){if(!e.targeting){if(e.time>=10){e.time=0;e.targeting=true;e.target=e.y+(random(2)?-150:150);}e.time+=dt*.1f;}if(e.targeting){float distance=e.target-e.y;if(fabsf(distance)>5)e.y+=(distance>0?1:-1)*2*dt;else{e.targeting=false;e.time=0;}}}
if(e.y<=30||e.y>620)e.y=old;
if(e.type==1){if(e.cooldown<=0){if(nh<100)hostile[nh++]={e.x,e.y+heights[e.type]/2,e.bs};e.cooldown=rnd(30,80);}e.cooldown-=dt*.5f;}
if(px<e.x+widths[e.type]&&px+30>e.x&&py<e.y+heights[e.type]&&py+24>e.y)damage();
}
for(int i=nh-1;i>=0;i--){auto &b=hostile[i];if(b.x<=0||inside(b.x,b.y,px,py,30,24)){if(b.x>0)damage();removeAt(hostile,nh,i);}else b.x-=b.speed*dt;}
if(spawnTime>spawnMax){spawnTime=0;/* Correct unreachable original >4000 branch. */spawnMax=elapsed>4000?rnd(20,50):elapsed>2000?rnd(50,100):rnd(150,200);spawnEnemy();}spawnTime+=dt;elapsed+=dt;
}
// Clip once per image, then write directly into the existing RGB565 strip.
// TFT_eSprite stores 16-bit pixels byte-swapped; preserve every source colour.
void image(const Image &im,int x,int y,bool transparent=true){
const int x0=max(0,-x),x1=min(im.w,320-x);
const int y0=max(0,stripY-y),y1=min(im.h,stripY+STRIP_HEIGHT-y);
if(x0>=x1||y0>=y1)return;
for(int j=y0;j<y1;j++){
const uint16_t* source=im.p+j*im.w+x0;
uint16_t* dest=stripPixels+(y+j-stripY)*320+x+x0;
if(transparent){for(int i=x0;i<x1;i++,dest++){uint16_t c=pgm_read_word(source++);if(c!=GREEN)*dest=(c>>8)|(c<<8);}}
else {for(int i=x0;i<x1;i++){uint16_t c=pgm_read_word(source++);*dest++=(c>>8)|(c<<8);}}
}
}
void center(const char* text,int y,int size=1){frame.setTextSize(size);frame.setTextDatum(MC_DATUM);::text(text,160,y);}
void render(){
for(stripY=0;stripY<225;stripY+=STRIP_HEIGHT){
frame.fillSprite(GREEN);int a=(int)(bg1*SCALE),b=(int)(bg2*SCALE);image(bg,a,0,false);image(bg,a+320,0,false);image(bg_2,b,159);image(bg_2,b+320,159);
for(int i=0;i<ne;i++)image(*enemyImages[enemies[i].type],enemies[i].x*SCALE,enemies[i].y*SCALE);
if(lives>0){if(shield>0&&((int)(shield/4.5f)%2))image(player_shield,(px-5)*SCALE,(py-5)*SCALE);image(player,px*SCALE,py*SCALE);}
for(int i=0;i<ns;i++)rect(shots[i].x*SCALE,(shots[i].y-1)*SCALE,2,1,TFT_BLACK);
for(int i=0;i<nr;i++)image(rocket,rockets[i].x*SCALE,rockets[i].y*SCALE-rocket.h/2);
for(int i=0;i<nh;i++)rect(hostile[i].x*SCALE,(hostile[i].y-1)*SCALE,2,1,TFT_BLACK);
for(int i=0;i<lives;i++)image(heart,3+i*9,5,false);
image(rocket,125,5,false);frame.setTextColor(TFT_BLACK,GREEN);frame.setTextSize(1);frame.setTextDatum(TL_DATUM);char s[32];snprintf(s,sizeof(s),"%02d",missiles);text(s,141,3);snprintf(s,sizeof(s),"%05d",score);text(s,243,3);
if(state!=1){rect(40,78,240,76,GREEN);if(state==0){center("SPACE IMPACT",99,2);center("PAUSA ou TIRO para iniciar",122);}else if(state==2){center("PAUSADO",99,2);center("PAUSA para continuar",122);}else {center("GAME OVER",99,2);center("TIRO para reiniciar",122);}
if(state==0||state==3){char recordText[32];snprintf(recordText,sizeof(recordText),"RECORDE: %05lu",(unsigned long)bestScore);center(recordText,143);}}
frame.pushSprite(0,7+stripY);
}
}
struct Button {int pin;bool stable=false,last=false;uint32_t changed=0;bool press=false;void read(){bool now=controlDown(pin);press=false;if(now!=last){last=now;changed=millis();}if(now!=stable&&millis()-changed>=25){stable=now;press=stable;}}};
Button fireButton{FIRE},pauseButton{PAUSE},specialButton{SPECIAL};
uint32_t nextFrame=0,lastShot=0,lastUpdate=0;
void setup(){Serial.begin(115200);for(int p:{FIRE,UP,DOWN,LEFT,RIGHT,SPECIAL,PAUSE})pinMode(p,INPUT_PULLUP);recordsReady=records.begin("space-impact",false);if(recordsReady)bestScore=records.getUInt("best",0);else Serial.println("Recorde indisponivel: falha NVS");randomSeed(esp_random());lcd.init();lcd.setRotation(1);lcd.fillScreen(TFT_BLACK);frame.setColorDepth(16);stripPixels=(uint16_t*)frame.createSprite(320,STRIP_HEIGHT);if(!stripPixels){lcd.setTextColor(TFT_WHITE,TFT_BLACK);lcd.drawString("Memoria insuficiente",10,30);while(true)delay(1000);}setupControl();render();nextFrame=lastUpdate=millis();Serial.printf("Space Impact ESP32 ready: RGB565 strip buffer %u bytes, free heap %u\n",320*STRIP_HEIGHT*2,ESP.getFreeHeap());}
void loop(){pollControl();fireButton.read();pauseButton.read();specialButton.read();bool redraw=false;if(pauseButton.press){if(state==0||state==3)resetGame();else state=state==1?2:1;redraw=true;}if(fireButton.press&&(state==0||state==3)){resetGame();redraw=true;}if(state==1&&fireButton.stable&&(fireButton.press||millis()-lastShot>=150.0f/GAME_SPEED)){shoot();lastShot=millis();}if(state==1&&specialButton.press){launchMissile();redraw=true;}uint32_t now=millis();if(redraw)lastUpdate=now;if((int32_t)(now-nextFrame)>=0){nextFrame=now+33;uint32_t passed=now-lastUpdate;lastUpdate=now;if(state==1){/* Real elapsed time, subdivided to preserve collision checks. Limit long stalls. */float remaining=min(passed,(uint32_t)250)*0.1f*GAME_SPEED;while(remaining>0.0f&&state==1){float step=min(remaining,3.3f);update(step);remaining-=step;}redraw=true;}}if(redraw)render();delay(1);}Download project files
Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace, diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.




