HashCoreAI
← Blog
Game dev

Cum am construit Neon Arcade: jocuri 3D în browser livrate pe web și mobil dintr-un singur codebase

How We Built Neon Arcade: 3D Browser Games Shipped to Web and Mobile From One Codebase

Publicat 25 aprilie 2026 · 7 min citire

Neon Arcade este colecția noastră de jocuri gratuite în browser, găzduită la games.hashcoreai.com. În momentul scrierii acestui devlog conține trei jocuri 3D originale, toate construite în casă la HashCoreAI:

Toate trei rulează în orice browser modern și, în același timp, în aplicațiile native pentru iOS și Android — din exact același cod. Acest articol explică deciziile de arhitectură care fac posibil acest lucru, inclusiv compromisurile pe care nu le-am evitat.

Stack-ul: Three.js + Vite + TypeScript

Fiecare joc este o aplicație web independentă: Three.js pentru randarea 3D pe WebGL, Vite pentru bundling și dev server, TypeScript pentru tot codul de joc. Hub-ul arcade — grila de carduri pe care alegi un joc — este o a patra aplicație Vite + TypeScript, separată de jocuri.

Le ținem deliberat separate. Fiecare joc are propriul package.json, propriul vite.config.ts și propriul build. Asta înseamnă că un bug de dependență din Tidefall nu poate dărâma Planet Surfer, iar fiecare joc poate evolua în ritmul lui. La build, un script de assembly (scripts/assemble.mjs) copiază hub-ul construit în rădăcină și fiecare joc construit sub propriul sub-path, producând un singur folder public/ pe care îl deployăm pe Firebase Hosting.

ComponentăServită laBuild
Hub arcadegames.hashcoreai.com/Vite + TS + Capacitor
Planet Surfer/planet-surfer/Three.js + Vite
Barrel Brigade/barrel-brigade/Three.js + Vite
Tidefall/tidefall/Three.js + Vite

Un detaliu mic dar important: fiecare joc se construiește cu base: './' în Vite. Asta face ca toate path-urile către asset-uri să fie relative, deci același build rulează corect la /tidefall/ pe hub, dar și dintr-un wrapper file:// sau dintr-un sub-path complet diferit. Nu trebuie să recompilăm un joc doar pentru că s-a schimbat originea care îl servește.

Decizia cheie: în aplicația nativă, jocurile se încarcă din web-ul live

Aceasta este decizia de arhitectură care definește întregul proiect. Aplicațiile native sunt construite cu Capacitor, care împachetează un build web într-un shell nativ iOS/Android cu o punte (bridge) către API-uri native.

Abordarea evidentă ar fi să împachetezi toate jocurile în interiorul aplicației. Noi am ales contrariul. Doar hub-ul este împachetat în aplicație; jocurile se încarcă din originea web live, https://games.hashcoreai.com/<id>/, chiar și înăuntrul aplicației native. În capacitor.config.ts permitem navigarea către acel domeniu, iar codul nostru de platformă decide URL-ul:

Pe web folosim path-uri relative same-origin. În aplicația nativă, jocurile se încarcă din aceeași origine web live. Câștigul: a adăuga un joc = deploy pe Firebase și apare instant în aplicație. Fără bundling, fără update în magazin, niciodată.

Concret, fluxul de adăugare a unui joc nou arată așa:

  1. Pui jocul în games/<id>/ și îl adaugi în scriptul de assembly.
  2. Îl adaugi în lista de jocuri (games.json, servit de pe domeniul web).
  3. Rulezi npm run deploy.

Atât. Web-ul și aplicațiile native deja instalate pe telefoanele oamenilor îl preiau — fără update în App Store sau Google Play. Hub-ul nu are jocurile în cod la momentul build-ului: la pornire face fetch la lista live de jocuri și o afișează. Lista împachetată în aplicație este doar un fallback offline.

De ce o singură origine web partajată

O singură origine ne dă trei lucruri gratis. Un singur cont Firebase și o singură sesiune de autentificare funcționează în toate jocurile, fiindcă rulează pe același domeniu. Un singur portofel de tokenuri este consistent peste tot. Și o singură conductă de deploy livrează simultan pe toate platformele. Costul de mentenanță al unui joc nou se apropie de zero din punct de vedere al distribuției.

Compromisul WebView vs. nativ

Această abordare nu este gratuită. Iată ce am cântărit, sincer:

AspectJocuri în web live (alegerea noastră)Jocuri împachetate nativ
Joc nouDeploy web, apare instantUpdate în magazin (zile de review)
OfflineNecesită conexiune (mitigat prin cache)Funcționează offline
PerformanțăWebGL în WebViewWebGL în WebView (identic)
Mărime aplicațieMică (doar shell-ul)Crește cu fiecare joc
Risc de respingere în magazinNecesită valoare nativă realăMai puțin risc

Cel mai important compromis: o aplicație care e „doar un site împachetat” riscă respingerea pe motiv de „minimum functionality”, mai ales pe App Store. De aceea am adăugat valoare nativă reală în shell, activată doar pe native și încărcată dinamic ca să nu afecteze build-ul web: bara de stare colorată în tema neon, butonul hardware de back pe Android care merge prin istoricul in-app (nu lasă utilizatorul într-un dead-end), blocarea orientării per joc (Tidefall pornește mereu landscape, restul portrait), feedback haptic la atingeri și o notificare locală de re-engagement programată local, fără server.

Performanța pe telefoane Android ieftine

WebGL prin WebView rulează la fel ca un joc web obișnuit — nu primești magie nativă gratis. Pe dispozitive de gamă joasă, GPU-ul și lățimea de bandă a memoriei sunt constrângerea reală, nu JavaScript-ul. Lecțiile noastre practice:

Strategia de încărcare și de asset-uri

Pentru că jocurile se încarcă din rețea, cache-ul HTTP este critic. Configurăm header-ele pe Firebase Hosting astfel:

Combinația dă ce ne dorim: actualizările apar instant, dar asset-urile grele 3D se descarcă o singură dată și apoi vin din cache. Hub-ul deține și PWA-ul (service worker-ul); jocurile nu înregistrează service workere proprii, ca să nu se lupte pe scope. La nivel de cod, SDK-urile grele (RevenueCat, de exemplu) sunt importate dinamic abia când sunt necesare, ca să țină pornirea ușoară.

Un cont, un portofel — server-authoritative

Un singur proiect Firebase deservește tot. Utilizatorii se autentifică o singură dată cu Google, Apple sau email și au același cont pe web, Android și iOS. Pe native, signInWithPopup nu funcționează în WebView, așa că rulăm fluxul nativ de provider și apoi semnăm credențialul în SDK-ul Firebase JS — astfel întreaga aplicație împarte o singură sesiune de autentificare.

Peste cont stă un portofel de tokenuri partajat între jocuri, stocat în Firestore. Punctul cheie de design este că este server-authoritative: regulile noastre Firestore permit clientului doar să citească propriul portofel; orice scriere a soldului este interzisă din client și se face exclusiv din Cloud Functions cu Admin SDK. Clientul se abonează la soldul live și reflectă schimbările în UI, dar nu poate inventa tokenuri.

Notă: tokenurile sunt valută pur virtuală în jocuri. Nu sunt bani reali, nu pot fi „câștigați” ca venit și nu există nicio componentă de investiție — sunt doar design de joc, ca monedele din orice joc clasic.

Ce am învăța

Separarea jocurilor de shell și încărcarea lor din web-ul live a fost decizia care a plătit cel mai mult. Eliberează echipa de ciclul de review din magazine pentru orice e conținut, păstrând în același timp un strat nativ subțire pentru lucrurile care chiar au nevoie de el. Compromisul — dependența de rețea — îl gestionăm cu o politică de cache disciplinată. Pentru un studio mic care publică multe jocuri mici, este exact echilibrul corect.

Vrei să le încerci? Toate jocurile sunt gratuite la games.hashcoreai.com. Iar dacă plănuiești o aplicație web sau mobilă care trebuie să livreze rapid pe mai multe platforme dintr-un singur codebase, scrie-ne la contact@hashcoreai.com — exact genul de arhitectură pe care o construim pentru clienți.

Citește mai departe