Skip to content

Architecture overview

Containers

flowchart TB
  subgraph phone [Phone browser / installed PWA]
    hubui[Hub UI<br/>React 19 + Vite 8]
    sw[Service worker<br/>Workbox]
    idb[(IndexedDB<br/>Dexie)]
    frame[Game iframe<br/>sandboxed, games origin]
  end

  subgraph server [Homelab · docker compose: dev 47xx, UAT 48xx]
    hubsrv[Hub server<br/>Caddy: public pages + app + /api proxy]
    api[Platform API<br/>ASP.NET Core 10]
    db[(PostgreSQL 18)]
    gamessrv[Games origin<br/>Caddy: static builds]
    docs[Docs<br/>Caddy: developer docs]
  end

  hubui -- "GET /api/*" --> hubsrv --> api --> db
  hubui <-- "postMessage (Game Contract)" --> frame
  frame -- "own files only (CSP)" --> gamessrv
  api -- "every few minutes: catalog.json" --> gamessrv
  hubui --- idb
  sw --- hubui
ContainerCodeResponsibility
Hubapps/hubProfiles, catalog, launcher, host side of the contract, local-first storage and (M2) sync.
Platform APIservices/apiIdentity (M1), registry, saves and scores (M2), parental settings. The only writer to the database.
Games origingames/* → tools/games-hostImmutable static builds under /<id>/<version>/ plus catalog.json.
Public pagesapps/webThe home page, the games’ pages, the install guide, the FAQ, about, the terms and the privacy notice (Vietnamese), built into the hub image and served by its Caddy at the root of the hub’s host (Hosts and paths).
Docsapps/docs + docs/The developer docs (English), at the root of their own host.
Admin consoleapps/consoleThe operator’s pages on a host of its own with its own sign-in, served by the hub image’s server on a second site (Admin console).

Origins

Three browser origins, on purpose:

OriginLocal (dev)UAT (homelab)Why separate
Hub + APIhttp://localhost:4720 (API also on :4740 for debugging)http://10.100.0.2:4820The public pages and the app share the origin, so the session cookie (M1) stays first-party; the API is same-origin, so no CORS.
Gameshttp://localhost:4730http://10.100.0.2:4830Game code — possibly AI-written — can never read the hub’s cookies or storage.
Docshttp://localhost:4710http://10.100.0.2:4810Static, independent of the app; stays up if the app is down.

Environments are described in ADR 0010. Production runs the same images on the platform-infra cluster through Tò He’s Helm chart, configured when the containers start (ADR 0021, Configuration). There all of Tò He is on toheplay.io.vn, a domain it has to itself: the hub at its root, the games origin at games.toheplay.io.vn, the docs at docs.toheplay.io.vn — one level down, which Cloudflare’s free certificate covers (ADR 0040). The games are the same site as the hub there, as locally and on UAT, where only the port tells the origins apart: the hub’s __Host- session cookie (on HTTPS), the API’s same-origin checks and the games’ CSP keep game code away from it, and no app that is not Tò He’s shares the domain. Which host and which path each part lives on, and how that grows, is Hosts and paths (ADR 0042).

Dependency rules

  • A game depends only on @tohe/game-sdk (and its own libraries). Never on the hub, the API, or other games.
  • The hub depends on @tohe/contract, @tohe/game-sdk/host and the generated API types. It never imports game code.
  • The API knows games only through catalog.json; it never loads game code.
  • @tohe/contract is the single source of truth for the contract. The C# catalog types and the Caddy CSP are derived from it and must be kept in step (tests and the build check this).
  • @tohe/routes is the single source of truth for the paths on the hub’s host. The hub’s Caddy routes, the service worker’s list of app pages, robots.txt and the API’s HubPaths are generated from it (Hosts and paths).

Modules inside the API

The API is a modular monolith (ADR 0003). Each module lives in services/api/src/Tohe.Api/Modules/<Name>/, registers its own services and endpoints, and owns its tables in its own PostgreSQL schema. Modules do not read each other’s tables.

ModuleStatusEndpoints
PlatformdoneGET /api/config
RegistrydoneGET /api/games, GET /api/games/{id}
Identity & Profilesdone/api/auth/*, /api/session/*, /api/account/*, /api/profiles/* — accounts own their profiles and sign in by email or a provider, switched and opened at run time (ADR 0023, 0024, 0026, 0028); see Identity
Saves & ScoresM2per-profile saves and runs; their boards come from Integrity
Integritythe engine and the account’s boards (ADR 0029)/api/boards/*; Progress and Rooms hand it their results — see Integrity and boards
Parentallaterplay-time limits, per-profile game visibility
Emaildone (ADR 0022)none — other modules add messages to its outbox
Telemetrydone (ADR 0015)/api/telemetry, /api/telemetry/report — see Device telemetry
Adminthe platform at a glance, users, sign-in and registration, communities (ADR 0027, 0028)/api/admin/*, an OpenAPI document of its own; reads what the other modules expose — see Admin console
Roomsdone (ADR 0016)/api/rooms/*, SignalR /api/rooms/hub — see Online rooms

Generated reference: Platform API.

Key flows

  • Launching a game — handshake, save, score, pause, exit.
  • Catalog: pnpm build assembles games → games origin serves catalog.json → the API polls it (Registry:SyncInterval) and upserts the registry → the hub lists GET /api/games.
  • A newcomer’s first touches — open and play, install, link previews, feedback.

A newcomer’s first touches

Opening the hub is playing (vision); nothing stands between a link and the first game.

  • No sign-in before playing. A device with no session plays as its device profile at once — the first visit makes one silently, with a suggested name (RequireSession, playerGate in apps/hub/src/session/session.ts); /account/sign-in opens only when chosen — from the catalog’s slim “Đăng nhập” strip, or for what needs an account. A room link (/r/<code>), a room, the rooms lobby and a replay keep asking a signed-out device to sign in (device={false}). Signing out is a choice and leads to the sign-in screen. Signing up asks whether the device profile is the person’s own, and signing in offers to bring each device profile with progress into the account (Identity).

  • Installing: one component, three ways (apps/hub/src/install/). chooseInstall is a pure decision from the environment, what the browser handed over, whether a game finished, whether the visit had its offer and when the player last said no:

    WhereWhatWhen
    Android, Chrome, Edge (beforeinstallprompt, kept from the start of the page)The card “Cài Tò He” on the catalog; its button calls prompt()After the first finished game, once per visit
    Safari on iPhone and iPad (User-Agent)A sheet with the steps of iOS 26: ⋯ → Chia sẻ → Thêm vào Màn hình chính → “Mở dưới dạng ứng dụng web”After the first finished game, once per visit
    The in-app browser of Zalo, Messenger, Facebook (User-Agent)A strip “Mở bằng trình duyệt” with “Chép link” and a hintOn arrival, on every screen until dismissed

    A finished game is a final score (or a ranked finish) from the Game Contract’s host side — no contract change. Never when running as the installed app (display-mode: standalone, navigator.standalone). “Để sau” and closing are silent for 14 days (localStorage tohe:install-dismissed). The catalog’s ”⋯” menu has “Cài app”, which opens the same prompt or sheet at any time (and says how by hand where none fits), and “Góp ý / báo lỗi”, a mailto: to [email protected] with the app’s version and the browser in the body.

  • The manifest (apps/hub/vite.config.ts): user-first description, start_url /play, a shortcut to Ghép Khối, screenshots made by pnpm --filter @tohe/hub generate:screenshots. index.html carries static og: and twitter: tags (title, description) for link previews; the preview image and og:url must be absolute and wait for the hub’s public URL at run time.

  • Tested by the hub’s unit tests (install.test.ts, session.test.ts) and the stack’s journey, pnpm --filter @tohe/e2e journey.