Project · Web service
TerminalGB Portal
Trade a Gen 1 Pokémon with a friend over the web, with one Game Boy each and no appointment. Both players trade against their own local link partner in their own time; the portal carries a description of the Pokémon between them and never a byte of game data.
Checks run 14 Aug 2026 against a local Postgres 16 and a real Chrome.
What it does
Trading
Nobody waits for anybody
- One player deposits a Pokémon; the other collects it whenever they next sit down. No session, no lobby, no presence
- Offers are addressed to named friends, are irreversible on accept with a confirmation, and auto-withdraw
- A trade log both sides can read
- Every incoming spec is validated against Gen 1 tables — species, moves, level, determinant values, name character sets — at deposit, and re-resolved by the receiving client against its own cartridge's tables
Collections
Sprites for the session only
- A friend's party and boxes as their own terminal reported them, sprites included, from their own emulator running their own cartridge
- Held for the session. When the terminal disconnects they are purged. No sprite store, no server-side fallback, no reusing one user's picture for another user's Pokémon of the same species
- A disconnected user shows no sprite at all. That is the correct behaviour, not a degraded one
- An offer whose Pokémon evolved has its stored picture nulled: a wrong picture is worse than none
Accounts
Sign in on a device with no browser
- Sessions belong to the authentication provider; nothing hand-rolled
- The CLI gets opaque bearer tokens — 256 bits of randomness, stored only as a SHA-256 hash, 30-day access and 90-day refresh, with rotation and reuse-detection revocation
- Device codes expire in ten minutes, are single-use, and enforce their polling interval with per-IP and per-user rate limits. Approval requires a signed-in browser session, never a bearer token
- Friend codes are cryptographically random from a 30-symbol alphabet, and the lookup endpoint returns one generic error for every failure mode, so codes cannot be enumerated
Language
Gen 1 has no shiny Pokémon
- What Gen 1 has is determinant values, from which Gen 2 derives both shininess and Hidden Power. So every surface reads "would be shiny if traded to Gen 2", never "is shiny"
- The portal recomputes the prediction from the determinant values itself, against the published Gen 2 rule, and never trusts the flag on the wire
- The Hidden Power line is the one line in a card allowed to wrap to two, because the full phrase does not fit. Wrapping varies a card's height, never its width
What is tested, and what is not
Two lists rather than one status badge.
| Tested, observed working | Written but not exercised |
|---|---|
All 65 end-to-end checks green: the full friend flow; offer, accept, done
and log through both cookie and bearer authentication; the complete
device-code flow including authorization_pending →
slow_down → approval → single-use consumption; token refresh
rotation with reuse revocation; non-enumeration; atomic double-accept
rejection; rate limiting. |
The authentication provider with real keys. No account was available in the build environment; everything follows the current major version's documentation and builds keyless, but the first deploy must smoke-test sign-in before anything else. |
| A browser walk in real Chrome: dev sign-in, friend request and accept, the offer form, the accept flow with its irreversibility confirmation, mark-done, the code approval page, the trade log. The production build passes with and without the dev auth stub. | The hosting and database deployment. Standard patterns, never run from here. |
| The collection browser walked in both states: live, with canvas sprites from a synthetic test payload and the filters exercised; and offline, with sprites purged, the listing intact and a last-seen shown. | Time-based expiries — device code at ten minutes, tokens at 30 and 90 days. Enforced in SQL, but no test waits them out. |
Not in v1, and the interface says so
Written into the product rather than into a roadmap document, which is the difference between a scoped v1 and a v1 that is quietly missing things.
Limits worth knowing
- Gen 1 has no anti-cheat, and the portal does not pretend to verify legitimacy. The trade log says "done" because each side attested it, and it says so in those terms.
- "Same Pokémon" is a stated heuristic, not an identity. Original trainer id and name, determinant values and nickname — with both-default nicknames matching across an evolution-line species change, because the game renames a never-nicknamed Pokémon when it evolves. Species is deliberately not in the key. Every ambiguity withdraws the offer rather than guessing.
- The live-link relay is real code, passing its own tests, and is not deployed. 118 lines of Cloudflare Durable Object, for future features. Nothing in the shipping app uses it. Before production it still needs token validation on the upgrade request, an idle timeout for abandoned sessions, and peer-fault propagation so one side's fault poisons the other's write instead of leaving a half-trade.
-
The rate limiter allows exactly max and denies the max-plus-first.
A data-modifying common table expression is invisible to the same statement's
SELECTunder Postgres's snapshot rules, so the limiter counts only prior attempts. An end-to-end check pins the eleventh attempt returning 429 so the meaning cannot drift.
Writing
Where these numbers come from
The test counts and their date are the repository's own recorded observations, kept in a section that separates what was run from what is standard practice. The six relay checks were verified green twice on 14 Aug 2026. The diagram is drawn from the shipping v1 architecture and the shared HTTP contract.
Pokémon, Game Boy and Nintendo are trade marks of their respective owners. This portal transfers a description of a Pokémon and never a byte of game data.