Community project

Space Impact Clone

Photo of Space Impact Clone

Sopra Jogar

Published November 12, 2026

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

Loading diagram…

Gather all the parts

QtyComponent
1

ST7789 TFT Display 2.0 inch

2.0-inch IPS TFT color display breakout driven by the ST7789 controller over 4-wire SPI. Native resolution is 320x240. Adafruit's breakout includes a 3.3V regulator, auto-reset circuit, 3V/5V level shifting, and a microSD holder sharing the SPI bus. Display drawing uses SCK, MOSI, CS, DC, and optional RST; MISO and SDCS are only needed for the onboard microSD card.

1

Push Button

Momentary push button switch

1

Push Button

Momentary push button switch

1

Push Button

Momentary push button switch

1

Push Button

Momentary push button switch

1

Push Button

Momentary push button switch

1

Push Button

Momentary push button switch

1

Push Button

Momentary push button switch

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"

FunctionlcdESP32
powerVCC3V3
groundGNDGND
spiSCKGPIO 21
spiMOSIGPIO 19
spiCSGPIO 23
digitalDCGPIO 18
digitalRSTGPIO 22
powerBL3V3

2. Connections between "fire" and "ESP32"

FunctionfireESP32
digitalSIGNALGPIO 4
groundGNDGND

3. Connections between "up" and "ESP32"

FunctionupESP32
digitalSIGNALGPIO 25
groundGNDGND

4. Connections between "down" and "ESP32"

FunctiondownESP32
digitalSIGNALGPIO 26
groundGNDGND

5. Connections between "left" and "ESP32"

FunctionleftESP32
digitalSIGNALGPIO 27
groundGNDGND

6. Connections between "right" and "ESP32"

FunctionrightESP32
digitalSIGNALGPIO 32
groundGNDGND

7. Connections between "special" and "ESP32"

FunctionspecialESP32
digitalSIGNALGPIO 33
groundGNDGND

8. Connections between "pause" and "ESP32"

FunctionpauseESP32
digitalSIGNALGPIO 13
groundGNDGND

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.

Open in Schematik