Selected Work - Engineering

Streetvers - a street-culture roguelike & card platform

A single-player street-culture roguelike for mobile and desktop, paired with a collectible character-card surface that drives the game's social and influencer reach - every card is both promo content seeded to influencers and the player's unlock for the character it depicts. A deployed FastAPI backend, a deployed TanStack Start admin panel, and a Unity 6 game client, built and operated solo on a hardened self-hosted stack.

StreetCard - collectible

Role

Solo Developer

Stack

Unity 6 · C# · FastAPI · SQLAlchemy 2.0 · PostgreSQL · TanStack Start · React 19 · Ubuntu VPS

Links

admin.streetvers.com · api.streetvers.com

Timeline

2025 - present

01Overview

Streetvers is a single-player roguelike on mobile and desktop, wrapped in a card-driven growth engine: every playable character has a collectible card - portrait, stats, rarity, lore - that doubles as social-media content for influencer drops and as a player's unlock for that character. I own all three pieces end-to-end: a FastAPI backend that's the canonical source of truth, a TanStack Start admin panel where staff author cards and seed drops, and a Unity 6 client targeted at iOS, Android, Windows and Mac - plus the hardened Ubuntu VPS they deploy to.

Today the backend and admin panel are live in production at api.streetvers.com and admin.streetvers.com, with role-based staff auth, server-verified JWTs, image upload, and a full content-management surface. The Unity client is a single-player roguelike prototype at version 0.4 built on the MoreMountains TopDownEngine - playable end-to-end with wave combat, a buff-card economy, a territory base layer, raids, and a meta-progression skill tree, but not yet wired to the backend. The next build is the owned-roster lookup: log the player in, read which cards they own from the API, and gate the character-selection menu to those.

The project spans 29 HTTP endpoints, 5 database models with 3 alembic migrations, 16 admin routes across 6 domain forms, and 190 C# scripts across 9 Unity scenes - running on a self-hosted, hardened Ubuntu 24.04 VPS rather than a managed PaaS.

Admin panel - character authoring with live StreetCard preview

02What I built

  • The game (Unity, mobile + desktop) - a single-player roguelike on the MoreMountains TopDownEngine: a wave / combat loop, a buff-card economy with 33 buff types drafted between waves, a territory base layer of 10+ capturable zones with hired guards/dealers and passive income, a raid simulator, a sigil meta-progression skill tree, and a monetisation-store UI. One Unity 6 project builds to iOS, Android, Windows and Mac.

  • Card-driven growth surface - the same character data that drives the roguelike's playable roster doubles as collectible promo cards - authored in the admin panel (portrait, stats, rarity, lore tags), then seeded as social drops aimed at influencers. The marketing asset and the in-game unlock are the same object - one render path, two audiences.

  • Backend (FastAPI) - the source of truth for cards, characters and rosters - 29 endpoints across auth, characters, abilities, teams, players, admin users and health. Polymorphic id-or-slug lookups, 8 stat fields with a server-canonical FIFA-style overall-rating formula, soft deletes, pagination/filtering, and a player ↔ card collection (PlayerCard M2M) that is the system of record for which characters a player can play.

  • Admin panel (TanStack Start) - 16 routes; full CRUD for characters (with a live StreetCard preview and an in-form OVR readout that mirrors the server formula), abilities, teams, players (with their collection), and admin users. Role-aware UI with super_admin / editor / viewer gating.

  • Auth & permissions - JWT with role + admin_user_id / player_id claims and a token-version revocation counter that invalidates outstanding tokens the moment an admin is deactivated. bcrypt hashing with a timing-attack-equalising dummy hash on failed login. Per-IP rate limits on /auth/token and /auth/register.

  • Image pipeline - dual-mode storage in the API: Cloudinary in production, local filesystem in dev, behind a single service interface. Portrait uploads land on the correct backend without the form ever knowing the difference.

  • Dual-mode admin - the admin panel's service layer swaps the real API for an in-memory mock at build time via VITE_USE_MOCK, so the panel runs and demos end-to-end with no backend.

  • Hardened self-hosted infrastructure - per-app system users, default-deny outbound firewall, AIDE filesystem-integrity + auditd syscall trails, an rrsync-restricted deploy pipeline that cannot run arbitrary commands, and PM2 file-watch in place of an SSH-and-restart step in CI.

03Architecture

Unity game

Unity 6 · offline · planned wiring

FastAPI

29 endpoints · JWT · 5 models

Admin panel

React 19 · TanStack Start · Vite

PostgreSQL 14

5 tables · 3 migrations

The backend is a self-hosted FastAPI service in front of PostgreSQL 14, deployed on a small Ubuntu 24.04 VPS under a dedicated svapi user, managed by PM2 and fronted by nginx with Let's Encrypt TLS. The admin panel is a TanStack Start app built to a Node-server adapter under a separate svadmin user with its own nginx vhost. The Unity client ships to four platforms from one project and runs fully offline today - the planned wiring reads the player's owned roster on launch. One managed third party is in play: Cloudinary for portrait CDN in production, with a local-filesystem fallback in dev.

Wave combat

Territory base layer

Buff-card draft

Sigil skill tree

04Technical challenges

Designing an API for a consumer that doesn't exist yet

Problem
The game (Unity 6 prototype at bundleVersion 0.4) is offline today. When it gets wired up, the first thing it'll do is read the logged-in player's owned roster and gate the character-selection menu to those - so the API contract has to be right now, not negotiated later.
Approach
The consumer's shape got pushed into the API ahead of any caller. GET /characters/{id} accepts either an int id or a slug (ADR 0006), so the game ships human-readable URLs. Player auth (POST /auth/token → JWT with role: player) is live and tested; the collection surface is shaken out daily by admin workflows. Every shape decision lives as a numbered ADR.
Result
When the Unity client gets wired up, the API doesn't change shape. The admin workflow has been the integration test for the contract the game will use.

A character form that is the card

Problem
The card is two things at once - promo art seeded to influencers, and the in-game character it depicts. Authoring shouldn't need a save-and-reload, and the staff preview shouldn't drift from what the marketing channel or the game's collection screen renders.
Approach
The FIFA-style weighted OVR formula lives twice on purpose: the Python API is canonical, and the admin panel mirrors it bit-for-bit in computeOverallRating(stats). The form renders a live StreetCard - the same component the game's collection views reuse. The snake_case ↔ camelCase boundary translation lives in services.ts (ADR 0009).
Result
The admin form is the card. Staff see exactly what a follower sees on Instagram and exactly what a player sees when they pick the character - one render path, three audiences.

A deploy gate the CI key can't escape

Problem
The usual CI shape - ssh user@host -- systemctl restart - gives the CI key arbitrary shell on production. A leaked Actions secret becomes a full compromise. I wanted a pipeline whose worst case was much smaller.
Approach
The admin-panel CI ships code, not commands. The deploy key is pinned via rrsync to a single destination path - it cannot run anything else over SSH. CI's entire verb set is rsync -az --delete. There is no restart step: PM2 watches dist/ and gracefully reloads when new bytes land. Per-app users keep the two apps isolated; the firewall is default-deny outbound.
Result
The worst-case blast radius of a leaked CI secret is 'stale assets in a folder' - not a shell on the box. Letting the service notice its own files changed replaces the most dangerous step in most CI pipelines.

JWT auth that revokes in one row write

Problem
The API serves staff (super_admin / editor / viewer) and end-game players. When an admin is deactivated, their outstanding tokens have to die now - not at the 60-minute expiry. And login probes shouldn't leak whether a username exists.
Approach
Every JWT carries the user's token_version as a tv claim. The decode path re-reads the current token_version on every protected request and rejects mismatches (ADR 0015). Deactivating an admin bumps the counter. Failed player lookups run a dummy bcrypt verify so wrong-username and wrong-password take the same time (ADR 0012); slowapi caps /auth/token at 10/min.
Result
Revocation is one UPDATE statement. Login probes return a uniform failure on a uniform clock, and the tokens floating around all carry the same claim shape.

Performance & UX

A dual-mode admin panel that runs with no backend at all

Problem
The admin panel needs to be demoable, screenshot-able, and developable without standing up the Python service and a PostgreSQL instance every time.
Approach
services.ts reads import.meta.env.VITE_USE_MOCK at build time and routes every domain to either the real HTTP client or an in-memory mock seeded with realistic characters, abilities, and teams. The mock implements full CRUD against an in-process store. Production sets VITE_USE_MOCK=false; dev defaults to mock.
Result
npm run dev shows a fully working admin tool with no API process and no database - useful for design iteration, screenshots, and onboarding before infrastructure is in front of someone.

One Unity project to four platforms

Problem
Mobile and desktop have very different input expectations and performance budgets, but I didn't want to fork the game into two projects.
Approach
A single Unity 6 project (built-in render pipeline, UGUI menus) on top of the MoreMountains TopDownEngine, with iOS / Android / Windows / Mac configured side-by-side in ProjectSettings. The prototype shares one input model; mobile autoaim, distance-based AI ticking, and frustum culling are scoped as the next pass.
Result
One project already builds to all four targets. The platform-specific polish is scoped, costed, and on the order list - not a structural blocker.

05Stack & why

FastAPI + SQLAlchemy 2.0 + PostgreSQL for the backend - typed Python with auto-generated OpenAPI docs, an async-capable ORM, and a real relational database for content that needs referential integrity and joins. TanStack Start + React 19 + Vite for the admin panel - file-based routing, SSR where it helps, and a build target I can run on a small Node process. Unity 6 + the MoreMountains TopDownEngine for the game - one codebase to four platforms, with a battle-tested combat substrate so the design budget goes to the buff economy, territory layer and meta-progression instead of input plumbing. The same character data drives both the playable roster and the marketing surface, so the API stays the one source of truth. Self-hosted on a hardened Ubuntu VPS - a deliberate trade of convenience for control, cost and substrate awareness.

Unity 6C#MoreMountains TopDownEngineFastAPISQLAlchemy 2.0PostgreSQLalembicPydanticJWTbcryptCloudinaryReact 19TanStack StartTanStack QueryViteTypeScriptTailwind 4shadcn/uiPM2nginxUbuntu 24.04GitHub ActionsAIDEauditd

06Outcomes

StatusBackend + admin in production · Unity client in pre-release 0.4
Productionadmin.streetvers.com · api.streetvers.com (Let's Encrypt TLS)
29HTTP endpoints (FastAPI, across 6 routers)
5 / 3SQLAlchemy models / alembic migrations
8pytest files covering auth, token revocation, RBAC, character smoke
15architectural decision records (adr/)
16admin-panel route files (TanStack file-based router)
190 / 9Unity C# scripts / scenes
33buff card types in the roguelike loop
10+capturable territory zones in the base layer
Token-version JWTone-row revocation on admin deactivation
Hardened VPSper-app users · default-deny egress · AIDE + auditd · rrsync deploy gate
Card surfaceOne render path · three audiences (admin form · social drop · in-game unlock)