kawaandClaude Opus 5.5 e5b3b4a91c
Checks / web (push) Failing after 18s
Private ROM checks / private-rom (push) Failing after 25m52s
Lay out the Hall of Fame, the Diploma and the credits per cartridge (#84)
The European releases keep these routines' instructions and change
their operands: where the count and the ID go, which tiles the ID line
draws, where the player's name follows on the Diploma. game/rom/code.ts
reads those operands from the cartridge (and refuses a routine whose
instructions differ). The credits read the copyright's string from
ParseCredits (six more strings precede it in the releases) and use spill
tables fitted to each release's credits on the cartridge, with Spanish's
one group that spills otherwise while B is held.

The reference specs for these screens now pass against all four
releases, as every other reference spec that applies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 03:01:10 +02:00
2026-09-22 10:57:54 +02:00

Crystal

A native web port of Pokémon Crystal, translated from PRET's Game Boy disassembly. The game runs in TypeScript and reads graphics, maps, sprites and dialogue from the user's own ROM. No original game assets are included.

Current milestone: the whole story, from the opening through Johto, the Pokémon League and Kanto to the battle against Red on Mt. Silver. Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. The Map tab also shows the Johto and Kanto maps with your location, the towns you can fly to and the areas open so far. Savegames keeps several independent playthroughs: start a new game, continue, rename, duplicate, export or delete any save, and preview a backup before importing it. The Pokédex tab shows the Pokémon you have seen and caught. Link and Friends show their planned scope without placeholder data.

Walk around your home, the town and its buildings at the original walking speed. Mom gives you the Pokégear and asks the day of the week. The teacher stops you from leaving town. Your rival shoves you away from the lab window. Professor Elm introduces his errand, and you choose Chikorita, Cyndaquil or Totodile. The Pokémon's picture and stats come from your ROM, and you can nickname it. Elm's aide then gives you a Potion. These scenes run the game's own event scripts and movement data, with native choices and naming.

With a Pokémon, head west along Route 29 to Cherrygrove City. Wild Pokémon appear in the grass and battle you with their original moves. The route has ledges, signs, an item ball and a Berry tree that regrows daily. Townspeople wander and look around as in the original. In Cherrygrove, the Guide Gent gives a tour, the Poké Mart buys and sells items, the Pokémon Center nurse heals your party, and a whiteout returns you to the last Pokémon Center you entered.

North of Cherrygrove, Route 30's trainers battle you. Bug Catcher Don spots you from a distance and walks over; Youngster Mikey battles when you talk to him. Each sends out two Pokémon, and beaten trainers remember you. The Berry house gives a Berry, and a Potion is hidden on the route.

Mr. Pokémon gives you the Mystery Egg, and Professor Oak gives you the Pokédex. Your rival challenges you in Cherrygrove on the way back, and Elm's aide gives you Poké Balls. Back on Route 29, the Dude shows you how to catch Pokémon. From then on, open PACK in a wild battle to throw a Poké Ball. Caught Pokémon join your party, and you can give them a nickname. In battle, PKMN switches Pokémon or opens a Pokémon's stats, and when one faints you choose the next. PACK's Items pocket uses healing items, revives, Ethers, X items and Poké Dolls.

Start opens the start menu. POKéMON shows your party: open a Pokémon's stats, change the order, rearrange its moves, or give and take held items. PACK shows your pockets: use Potions, status healers, revives, Ethers, PP Ups, vitamins, Rare Candy and evolution stones, give or toss items, and ride the Bicycle. Pokémon evolve when they level up after a won battle (press B during the flashes to stop it), and learn new moves outside battle too. Field moves work from the party menu too: Cut, Surf, Strength, Flash, Whirlpool, Waterfall, Sweet Scent, and Softboiled or Milk Drink to share HP. Fly opens the Fly map to choose a town you have visited, Teleport returns to the town of the last Pokémon Center you entered, and Dig or an Escape Rope leads out of a cave through the entrance you last used. In PACK, SEL registers a key item such as the Bicycle, and Select then uses it. POKéGEAR shows the clock, the Town Map card with a cursor over every landmark, the phone and the radio. Call Mom, Professor Elm or the trainers who gave you their number; they call back now and then with news, rematches, gifts and swarms. Tune the radio to Oak's Pokémon Talk, the Pokédex Show, Pokémon Music, the Lucky Channel or, from 6 PM, Buena's Password, whose password earns Blue Card points at the Radio Tower. House radios play too. Call Mom after the catching lesson and she offers to save some of your trainer prize money. Once the Mystery Egg is delivered, talk to her at home to save or take money, or to switch Daylight Saving Time; with enough saved, she buys items and dolls for you and calls to say so. POKéDEX lists Pokémon in Johto order up to the last one you have seen. Open a seen Pokémon to read its number, category and footprint and hear its cry (CRY plays it again); once caught, its height, weight and two pages of text appear. Your name in the start menu opens the trainer card: your ID, money, Pokédex count and play time, then the Johto leaders with the badges you have earned. OPTION sets the battle style (SET skips the offer to switch when a trainer sends out the next Pokémon), the sound (mono or stereo) and the menu account (the start menu's descriptions); text speed, battle scene, print and frame are kept for later. Bill's PC can move Pokémon between the party and any box. The poster opens the Town Map.

The Pokémon Center PC opens Bill's PC, your own PC and, once you have the Pokédex, Professor Oak's rating. Deposit, withdraw and release Pokémon, and switch or name boxes. Store, withdraw or toss items. With six Pokémon in your party, a catch goes to the current box. When that box fills up, Bill calls you. The bedroom PC also lets you arrange owned decorations.

Route 31 leads to Violet City, Sprout Tower and Falkner's gym. South of Violet, Route 32, Union Cave and Route 33 lead to Azalea Town: follow Kurt into the Slowpoke Well to stop Team Rocket, beat Bugsy, then herd the Farfetch'd in Ilex Forest for HM01 Cut and reach Route 34. Kurt makes balls from apricorns overnight. In the Ruins of Alph, solve the chamber puzzles to unlock wild Unown, which appear in their letters and fill the Unown Dex.

In Goldenrod, get the Bicycle, challenge Whitney for the Plain Badge and answer the Radio Tower quiz for the radio card. Visit the department store and Underground shops, the Name Rater and haircut brothers, check the Lucky Number show, or exchange Game Corner coins for prizes. The Route 35 gate gives you Kenya with Randy's MAIL. In the Game Corner, play the slot machines and Card Flip for coins.

North of Goldenrod, Route 35 leads through the National Park to Route 36. On Tuesdays, Thursdays and Saturdays, enter the Bug-Catching Contest: catch the best bug you can with the Park Balls before time runs out, then wait for the judging and the prizes. Water the odd tree on Route 36 with the SquirtBottle from Goldenrod's flower shop, then follow Route 37 to Ecruteak City. Beat the five Kimono Girls in the Dance Theater for HM03 Surf. In the Burned Tower, your rival challenges you before the floor gives way and three legendary Pokémon flee; Raikou and Entei then roam Johto's routes, where you may meet them in the grass. Morty is then back in his gym for the Fog Badge.

West of Ecruteak, Routes 38 and 39 lead past the MooMoo farm, where seven Berries cure the sick Miltank, to Olivine City. Your rival is there ahead of you, and Jasmine is at the top of the Lighthouse with her sick Ampharos. The Café gives HM04 Strength and a fisherman gives the Good Rod. Surf Routes 40 and 41 to Cianwood City: Suicune flees across the water and Eusine challenges you, the pharmacy hands over the SecretPotion, and Chuck waits behind the boulders of his gym for the Storm Badge. His wife gives HM02 Fly. Mania lends you Shuckie, the Poké Seer reads where you met your Pokémon, and the photographer offers a photo, which needs a Game Boy Printer. Bring the medicine back to the Lighthouse, then challenge Jasmine for the Mineral Badge.

East of Ecruteak, Route 42 runs past Mt. Mortar to Mahogany Town. North of town, Team Rocket charges a toll at the Route 43 gate, and the Lake of Rage is full of Gyarados forced to evolve. Stop the red one, then join Lance in the hideout under Mahogany's souvenir shop: get past the cameras and traps, learn the passwords, beat the executives and make the Electrodes faint for HM06 Whirlpool. Pryce's gym is an ice floor to slide across for the Glacier Badge. The fishing guru by the lake measures your Magikarp against the record.

Team Rocket then takes over Goldenrod's Radio Tower. Fight up to the fake director for the Basement Key, open the Underground's locked door, work the switches past your rival to free the real director in the warehouse, and use his Card Key to reach the executive on the top floor. The director gives you the Clear Bell. East of Mahogany, Route 44 leads into Ice Path: push a boulder down a hole so it stops your slide on the ice below, and come out in Blackthorn City. Clair's gym hides its way forward behind boulders that fall from the floor above. Beat her, pass the elder's test in the Dragon Shrine for the Rising Badge, and receive a Dratini that knows Extremespeed. The Move Deleter makes a Pokémon forget a move.

Routes 45 and 46 lead south from Blackthorn back to Route 29. In Ecruteak, the Clear Bell convinces the sages to let you into Tin Tower, where Suicune puts you to the test. After the League, Tin Tower's upper floors lead to Ho-Oh, and the Whirl Islands hide Lugia for a trainer with the Silver Wing.

With the eight badges, surf east from New Bark Town along Route 27, climb Tohjo Falls with Waterfall and take Route 26 north to the Victory Road gate, where only a trainer with all eight badges may pass. Your rival waits at the end of Victory Road. At Indigo Plateau, the Elite Four's doors lock behind you: beat Will, Koga, Bruno, Karen and then Lance, and enter the Hall of Fame. Your team is recorded as the original does, with each Pokémon's entrance, your play time and Professor Oak's rating, then the credits roll; you then wake up in New Bark Town. The Hall of Fame's teams can be viewed from any Pokémon Center's PC.

After the League, Professor Elm gives you an S.S. Ticket: the S.S. Aqua sails from Olivine to Vermilion City, once you find a lost girl and send a lazy sailor back to work. All of Kanto is open, with its eight gyms. Saffron's Magnet Train rides to Goldenrod and back once the Power Plant's Machine Part is returned; Lavender's Radio Tower gives you the EXPN card, and its Poké Flute channel wakes the Snorlax blocking Diglett's Cave. Blue waits on Cinnabar and later in Viridian's gym, Bill's grandpa wants to see some Pokémon, Celadon's game designer gives a Diploma for a complete Pokédex, and Goldenrod's move tutor teaches Flamethrower, Thunderbolt or Ice Beam on Wednesdays and Saturdays. With all sixteen badges, Professor Oak opens Mt. Silver: Red waits at the top of Silver Cave, and the credits roll again after you beat him.

On Route 40, the Battle Tower takes a party of three different Pokémon holding different items into a Battle Room of the level you choose, against seven trainers in a row. Win them all for five of a random vitamin. Between battles you can save and quit, and the receptionist picks up where you left off.

The music and sound effects play from your ROM's own sound engine: map music, battles and their cries, fanfares, jingles and fades. Sound starts with your first press (browsers require it) and pauses with the game; Settings turns it down or off. Move animations and their sounds are not ported.

Trade evolutions, the Pokédex's area view, search and other list modes are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.

Run

Requires Node.js 22.12 or newer.

npm ci
npm run dev

Open the printed local URL and select your uncompressed English Pokémon Crystal ROM (.gbc, 2 MiB): the US/Europe revision 1.1 or 1.0, or the Australian release. Its checksum is verified locally. Each cartridge keeps its own saves, and a backup exported from one imports into another. The French, German, Italian and Spanish releases are recognized and their support is in progress; the Japanese release is not supported. ROMs are never uploaded or stored in browser storage; select yours again after reloading to resume. A new game starts with Professor Oak's introduction, where you choose your character, set the clock and pick a name.

Use onscreen controls, or focus the game screen and use arrows to move, X to interact and X / Z to continue dialogue. Settings changes these keys, the text size and the sound volume for this browser. In choices, Up / Down change the answer, X confirms and Z answers No. Games save every 10 seconds and when paused, each into its own save. Switching apps pauses play. Download a backup before clearing browser data.

Native saves are separate from emulator snapshots and cartridge .sav files, but Savegames → Import backup also reads a .sav of the English Crystal (from an emulator, a cartridge dump or the 3DS Virtual Console's sav.dat) and converts it into a new save after a preview. The cartridge's checksums decide which of its two copies is read, as the game would. With an emulator clock footer (VBA-M, mGBA, BGB, SameBoy), the game clock carries on from the cartridge's; otherwise it follows the device and the day continues from the save. Saves made in an area the port has not opened yet, during the Bug-Catching Contest or in a Battle Tower challenge are refused with the reason. Each save keeps its newest and previous write; if the newest was interrupted or damaged, the previous one loads and nothing is overwritten. The single save of earlier versions becomes "Save 1" the first time this version opens it. Saves and backups from a newer version are kept as they are and refused, never rewritten. Saving during a scene keeps the progress from before it, so the scene replays after loading. The time you give Oak is kept as an offset from the device clock (the steppers start at the device's time), and the weekday you give Mom as an offset from that clock's weekday. The tile counter measures in-game movement, not real-world steps.

Deployment

Crystal is a static application: no application server, database or server-side save volume is needed. Each player supplies their ROM locally, and saves stay in their browser. Serve it at the root of a dedicated hostname over HTTPS; HTTP on localhost also works for local testing. Keep the same public origin when updating so players retain access to their browser saves.

Docker Compose

Images are published to git.azuze.fr/kawa/crystal:<tag> for Linux amd64 and arm64. Choose a tag from the Gitea packages. Save this as compose.yaml (also included in this repository):

services:
  crystal:
    image: git.azuze.fr/kawa/crystal:${CRYSTAL_TAG:?Set CRYSTAL_TAG to a published image tag}
    ports:
      - "127.0.0.1:${CRYSTAL_PORT:-8080}:8080"
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

Create a .env alongside it with CRYSTAL_TAG=prod for production or CRYSTAL_TAG=dev for testing. Each channel becomes available after its first successful publication. To pin a particular build, use its version tag (for example v0.1.0) or dev-<commit SHA> instead. Then start:

# Only needed if the package is private; use a token with read:package.
docker login git.azuze.fr
docker compose pull
docker compose up -d

Open http://localhost:8080. Set CRYSTAL_PORT=8081 for a second stack on the same host, using a separate Compose project name (for example docker compose -p crystal-dev up -d). For access from other devices, configure your HTTPS reverse proxy to forward the dedicated hostname to http://127.0.0.1:8080 (or the selected port). If the proxy is itself a container, put it on the same Docker network and forward to the appropriate stack's service on port 8080 instead. The application container serves HTTP on port 8080 as an unprivileged user and includes a health check. Only /tmp needs to be writable.

To update a channel, repeat docker compose pull && docker compose up -d with the same project name. To pin a version or roll back, change CRYSTAL_TAG to the desired published build first. Installed clients offer the new version through their Update button.

To build and run locally without the registry:

docker build --pull -t crystal:local .
docker run --rm -p 127.0.0.1:8080:8080 --read-only --tmpfs /tmp crystal:local

The multi-stage Dockerfile builds with Node 22 and serves only dist/ with Nginx. The build runs check:release; .dockerignore restricts the build context to native application inputs, excluding local ROMs, saves, reference tools and secrets.

Bare-metal (Debian / Ubuntu with Nginx)

Install Node.js 22.12 or newer and npm on the build machine. Clone the repository, select the Git tag or commit to deploy, and build as your normal user:

git clone https://git.azuze.fr/kawa/Crystal.git
cd Crystal
git checkout <git-tag-or-commit>
npm ci
npm run build
npm run check:release

On the server, install Nginx and copy only the contents of dist/ into a dedicated document root. These commands assume the build is on the server; if built elsewhere, transfer dist/ there first. Node.js is only needed for building.

sudo apt-get update
sudo apt-get install -y nginx rsync certbot python3-certbot-nginx
sudo install -d -m 755 /var/www/crystal
sudo rsync -a --delete --chmod=D755,F644 dist/ /var/www/crystal/

Create /etc/nginx/sites-available/crystal, replacing crystal.example.com with your hostname:

server {
    listen 80;
    listen [::]:80;
    server_name crystal.example.com;
    root /var/www/crystal;
    index index.html;

    add_header Cache-Control "no-cache" always;

    location /assets/ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        try_files $uri =404;
    }

    location / {
        try_files $uri $uri/ =404;
    }
}

Point the hostname's DNS to this server and allow inbound ports 80 and 443, then enable the site and obtain its HTTPS certificate:

sudo ln -s /etc/nginx/sites-available/crystal /etc/nginx/sites-enabled/crystal
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx --redirect -d crystal.example.com

Open https://crystal.example.com. For updates, check out the desired revision, repeat npm ci, the build and release check, then synchronize dist/ again. Nginx does not need restarting for static file updates. The shell and service worker revalidate, while Vite's fingerprinted assets are cached for a year. Keep this site at the hostname root with the configuration above; subdirectory hosting needs a matching Vite base path and server configuration.

Gitea image publication

Publish container image separates production releases from manual test builds:

Trigger Published image tags Deployment channel
Push Git tag v1.2.3 or 1.2.3 Original version tag and prod Production
Manual dispatch with dev-<commit SHA> Requested dev tag and dev Test

Configure these repository Settings → Actions → Secrets first:

Secret Value
REGISTRY_USERNAME Gitea login of the account publishing packages under kawa.
REGISTRY_TOKEN Personal access token for that account with write:package permission.

The workflow uses an explicit registry token for Gitea container registry authentication. The publishing account must be allowed to write packages under the repository owner. For a fork, the image path automatically follows <Gitea host>/<owner>/<repository in lowercase>; adjust the Compose image accordingly.

The existing docker-build runner must have a Docker daemon and allow the job to mount /var/run/docker.sock. In the runner configuration, allow that exact path in container.valid_volumes and restart the runner after configuration changes. This grants jobs access to the host Docker daemon, so use a runner reserved for trusted repositories. The workflow installs its Docker client and Buildx; no privileged Docker-in-Docker service or QEMU is needed because the static build runs on the builder's own architecture.

  • Production: push a stable version tag on a commit containing this workflow: git tag v0.1.0 && git push origin v0.1.0. That commit is built and published as both git.azuze.fr/kawa/crystal:v0.1.0 and git.azuze.fr/kawa/crystal:prod. Accepted versions are MAJOR.MINOR.PATCH, optionally prefixed with v, without leading zeros, prerelease suffixes or build metadata. Other Git tag pushes fail validation before building or publishing.
  • Test: open Actions → Publish container image → Run workflow, select the source branch or Git tag, and fill in the required tag field with dev-<commit SHA>. Use 7–40 lowercase hexadecimal characters matching the selected source's commit, for example dev-abcdef0. The workflow verifies the prefix against the checked-out commit and fails on a mismatch. It builds the selected source; the input does not switch the checkout to another commit. Obtain the identifier with git rev-parse --short=12 HEAD after checking out the intended source. This publishes the requested dev tag and git.azuze.fr/kawa/crystal:dev, even when the selected source is a release tag. A manual run cannot publish to prod. The workflow must be on the default branch for the manual entry point to appear.

Each channel tracks the last successful publication to that channel, not the highest version number or newest Git commit. Republishing an older release deliberately moves prod back; concurrent builds update their channel in publication order. No shared latest alias is published. Before publishing, the workflow checks release isolation and starts a temporary container to verify that the page and service worker are served with the same read-only settings as Compose. Published images record the source commit in OCI image labels.

Automatic deployment with Dockhand

Create two Git stacks using this repository and compose.yaml, with distinct stack names and HTTPS hostnames:

Stack Compose variables Webhook secrets in Gitea Actions
crystal-prod CRYSTAL_TAG=prod, CRYSTAL_PORT=8080 DOCKHAND_PROD_WEBHOOK_URL, DOCKHAND_PROD_WEBHOOK_SECRET
crystal-dev CRYSTAL_TAG=dev, CRYSTAL_PORT=8081 DOCKHAND_DEV_WEBHOOK_URL, DOCKHAND_DEV_WEBHOOK_SECRET

Configure each stack's Compose variables in Dockhand, enable its webhook and copy the URL and secret into the corresponding Gitea secrets. Give Dockhand registry credentials with read:package if the images are private. On both stacks enable Re-pull images and Force redeployment, so an unchanged Compose file still picks up a newly published channel image. The runner must be able to reach the webhook. See the Dockhand Git integration and CI/CD documentation.

After a successful image push, the workflow sends a signed request only to that channel's webhook. Both webhook secrets are optional: when neither is configured for a channel, publication succeeds without requesting deployment. Configuring only one is an error. A failed webhook marks the workflow failed, but the image is already published; correct the configuration and redeploy the stack in Dockhand. A successful request confirms acceptance, so check the actual deployment result in Dockhand.

Alternatively, leave webhook secrets unset and enable Dockhand's scheduled image checks with automatic updates for the stacks following prod and dev. These follow changes to the channel image; a stack pinned to a version stays on that version. Production and test should use different hostnames so their browser saves and service-worker caches remain separate.

Reference emulator

During development, open /reference.html to run the original game for comparison. It has separate saves and does not run inside the native player. Reference tools and the emulator are excluded from the default build.

npm run build             # Native application in dist/
npm run check:release     # Check the native distribution
npm run build:reference   # Optional reference in dist-reference/

Serve dist/ through an HTTPS static host, or use npm run preview locally. Over HTTPS the app can be installed from Savegames (or Safari's Add to Home Screen) and opens offline; select the ROM at each launch. Updates wait until you choose Update. Subdirectory hosting is supported with npm run build -- --base=/Crystal/. Use HTTPS from another device; ROM verification and save locking require a secure context.

Development

npm test
npx playwright install chromium webkit
npm run test:e2e
npm run check:vendor

Local-ROM tests use Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc at the root, or CRYSTAL_ROM_PATH, which may also point to PRET's 1.0 or Australian rebuild (make -C .reference/pokecrystal crystal crystal_au); tests/revisions.test.ts and tests/reference-revisions.spec.ts use those rebuilds when they are present. They skip when absent; no test downloads a ROM. Game files, private reference builds and gameplay screenshots must remain ignored.

CI checks run on pushes and pull requests, or manually from Actions. The workflow uses the shared docker-build runner with Node 22 in a Debian container. It installs from the lockfile, runs synthetic unit and Chromium/WebKit browser tests, builds both applications, and checks vendor integrity and native release isolation. CRYSTAL_ROM_PATH points to a verified absent file, so ROM-dependent tests skip; check:reference runs separately with the private ROM and reference build.

Private ROM checks also run on pushes to main, or manually with main selected. Set the repository Actions secret CRYSTAL_ROM_URL to a direct HTTPS download URL accessible to the runner. The job rejects redirects, verifies the 2 MiB Crystal 1.1 SHA-1, builds the pinned PRET reference with RGBDS 1.0.3, runs check:reference, then runs unit/playthrough and Chromium/WebKit gameplay/reference tests. The URL is exposed only to the download step. No ROM, reference build, save or gameplay artifact is uploaded or cached; private files and browser output are removed even after test failure. Pull requests retain the synthetic workflow. A missing or inaccessible secret fails the private job instead of silently skipping its tests. Manual-device and visual acceptance cards still require their recorded checks.

To reproduce the CI test selection locally:

CRYSTAL_ROM_PATH=/nonexistent/crystal-ci.gbc npm test -- --maxWorkers=2
CRYSTAL_ROM_PATH=/nonexistent/crystal-ci.gbc npm run test:e2e -- --workers=2

git config core.hooksPath .githooks keeps the forge's project board in step with commits: Closes #N in a message moves card N to Fait and closes it, any other #N moves it to En cours, and a commit on main without a reference adds a closed card to Fait. [no-card] skips a commit. See project-board.mjs.

The full port plan covers remaining game systems, the world map, Pokédex, internet Link, friends and optional mobile step support for egg hatching. See the architecture and routine map and verification for module boundaries and reference checks.

References: PRET documentation, wiki and source. The development reference uses binjgb under its MIT license; see provenance.

Independent project. Not affiliated with Nintendo or Game Freak.

S
Description
No description provided
Readme MIT
3.6 MiB
0 Stars 2 Watchers 0 Forks
Languages
TypeScript 96.1%
JavaScript 2.2%
CSS 1.6%