Skip to content

Achievements

Achievements are badges only: they pay no cash, XP or items. An achievement is done while its condition holds on the save, so nothing records the moment it unlocks. Old saves get theirs as soon as the game loads, and the race server and the game can never disagree about them. The design is in docs/2026-11-08-achievements-inbox-design.md.

player/achievements/ holds the list, with no Phaser, so the server could run it too. Each entry is { id, name, description, icon, done(state) }:

  • careerAchievements.ts: first win, each district’s boss, every star in each district (the “sweep”), and every boss. Bosses are named by district, so a locked badge gives nothing of the story away.
  • garageAchievements.ts: owning 3, 5 and 8 cars, a car with every part in sim/upgrades.ts at its top stage (a single part just fitted; one boost kind), a repainted car, a car with decals, and 1, 10 and 50 online wins.
  • progressAchievements.ts: levels 5, 10, 25 and 50, 25, 50 and 100 stars across the career, and a first car of the Tuner, Pro and Hyper class. The level thresholds are estimates, to set against the year-long pace.
  • styleAchievements.ts: garage firsts, most of them reachable in the first hour: a saved tune, a brand part, a custom underglow, custom rims, a metallic paint.
  • recordAchievements.ts: the numbers a drag racer quotes. A first dyno pull; quarter-mile times from 12 seconds down to the 7s, from a car’s best quarter-mile test run (quarterBest in its test runs: bestSeconds mixes distances); and 300, 500, 750 and 1000 wheel horsepower, from the car’s build on the dyno. The thresholds follow the class bands (scripts/bands-check.ts).
  • racingAchievements.ts: lifetime counts: 100 and 1000 races finished, 10 and 100 perfect launches, 100 and 1000 perfect shifts, 10 and 50 quick race wins, 10 and 50 weekly event wins.

achievements.ts puts them in four groups, one tab each on the screen (Career, Garage, Records, Racing), at most 18 per group. Adding one is a new entry in a file above, in a group. tests/achievements.test.ts checks the ids are unique and that a new game has none.

The save does not otherwise know how many online races a player won, so settleOnlineHeat (player/level/onlineXp.ts) adds one to onlineWins when the race server pays the XP for a won heat. It counts only heats inside the daily XP cap (20 a day), so two accounts racing each other cannot farm the badge. The 10 and 50 win badges say so. The game’s sync never sends the field (player/garageSync.ts): only the server writes it.

The save does not otherwise count races, launches or shifts, so raceCounts in the garage (player/raceCounts.ts) does. The race server writes it while it settles a race it judged: settleSingleRace (race/judge/settleRace.ts) for career, quick and weekly races, with the launch zone and shift grades the replay already made, and settleOnlineHeat for online heats inside the daily cap (finishes and wins only: online heats carry no grades). Boss fights and tournaments settle per series and are not counted. The game’s sync never sends the field, so only the server changes it.

  • Top bar (ui/topBar/achievementsButton.ts): the gold shield with a star next to the rank badge opens the screen.
  • Achievements screen (achievements/scenes/Achievements.ts, its own feature module): one group of badges in a grid, done ones lit gold, a toggle across the top to switch group (Career, Garage, Records, Racing), and the count done of all badges above the panel. The glyphs are drawn in code (achievements/view/achievementIcons.ts) until there is art.

On the player’s Play Games profile (Android)

Section titled “On the player’s Play Games profile (Android)”

Our badges stay the source of truth. Platform achievements (Google Play Games on Android now, Apple’s Game Center and Steam later) only copy them. The plan and the reasons are in docs/2026-11-12-play-games-plan.md.

The game runs in a WebView inside the Android app, and Google’s Play Games library (play-services-games-v2) is native only, so the game reaches it through a Capacitor plugin. There is no official one, so the plugin lives in the app instead of in a package:

Piece File Job
Native plugin android/app/src/main/java/com/neondrag/game/GameServicesPlugin.java signIn, unlock, showAchievements on Google’s library; registered in MainActivity.java
Project id android/app/src/main/res/values/strings.xml (game_services_project_id), read through the com.google.android.gms.games.APP_ID meta-data in AndroidManifest.xml empty: the plugin never starts the SDK
Plugin interface player/achievements/gameServices.ts registerPlugin('GameServices'), sign-in at start, the Play Games mirror
Mirror player/achievements/mirror.ts sends every done badge to the platform
Id map player/achievements/platformIds.ts (PLAY_GAMES_IDS) our badge id to Play’s CgkI... id; empty: Play Games stays off
Button achievements/view/gameServicesButton.ts “Play Games” on the achievements screen, opposite Back

At start (Boot), the Android app asks the plugin whether Play already signed the player in. Play does this on its own for a player who has used Play Games before, and shows no UI. If signed in, the mirror starts: it sends every badge that is done on the save, then listens to every new garage value (onGarageChanged in player/garageState.ts: a local change and the server’s copy alike, so a badge earned on the web or another phone arrives with the next sync). Play ignores an unlock that already happened and queues one made offline, so sending a badge twice is harmless. The mirror only remembers, per session, what it already sent.

The “Play Games” button opens Play’s own sign-in screen if the player is not signed in, starts the mirror after a successful sign-in, and then opens Play’s achievements screen.

Play Games is optional for the player. Every plugin call resolves, even when nobody is signed in or the phone has no Play services; a failure is logged once (Play Games: in the console) and the game goes on without it. The web, the portals and iOS never load it: hasGameServices() needs the app build (TARGET.id === 'app'), Android, and a filled id map.

  1. Add it to player/achievements/ as above.
  2. Create the same achievement in Play Console (Play Games services, Achievements) and copy its id.
  3. Add the row to PLAY_GAMES_IDS. npm run achievements:check (part of npm run check) fails while a badge has no row, or a row names a badge that is gone, once the map has any rows.

Creating an achievement in Play Console needs no app release; the game unlocks it from the first build with the new row.

  • Nothing happens on Android: check game_services_project_id and PLAY_GAMES_IDS are both filled, then chrome://inspect the app’s WebView and call Capacitor.Plugins.GameServices.signIn({ interactive: true }).
  • Play’s sign-in screen loads, closes, and the button asks again (seen 2026-11-12 on build 1038): the Google account is not a tester of the Play Games project while it is a draft. Only its own testers list counts (Play Console, Play Games services, Testers); being an internal tester of the app is not enough. ruben.heymans@gmail.com and info@neondrag.com are on it, and the internal track is linked.
  • Still to confirm on a phone: signing keys that Play Games does not know, ids from another project.

Game Center gets a GameServicesPlugin.swift with the same three methods on Apple’s GameKit, registered in a CAPBridgeViewController subclass, its own id map next to PLAY_GAMES_IDS (Game Center ids are ours to pick), and hasGameServices() widened to iOS. Nothing in the mirror or the badges changes.