Builder's Manual · Night Arcade Cabinet
Speedy Bird Service Manual
Source-backed guides to the speed curve, ReactLynx architecture, sprites, native hosts, documentation site, and release pipeline.
Speedy Bird Lynx
Flappy Bird clone built with ReactLynx and TypeScript. The checked-in project runs on Android and Web from a single codebase and includes iOS host source files for Xcode project setup. Lynx uses a native C++ rendering engine and dual-threaded architecture instead of a WebView.
Wiki Pages
| Page | Description |
|---|---|
| About Lynx | Native rendering, ReactLynx concepts, and platform boundaries |
| Architecture | Project structure, component hierarchy, rendering approach, and dual-threaded model |
| Assets and Sprites | Sprite organization, asset loading, tile-based pipe rendering, and audio |
| CI/CD Pipeline | GitHub Actions workflows for building, signing, deploying, and releasing |
| Game Engine | Physics, collision detection, state machine, scoring, and the game loop |
| Getting Started | Setup, dev server, production builds, and platform-specific instructions |
| Native Host Apps | Android host and iOS source scaffold |
| Dependencies and Upgrades | Current toolchains, compatibility holds, and upgrade checks |
Key Features
- Tap/click to flap in the ReactLynx app; the GitHub Pages canvas demo also supports Space
- Speed increases 1% per pipe cleared
- Medal system: Bronze (10+), Silver (25+), Gold (50+), Platinum (100+)
- Element-based ReactLynx rendering with CSS transforms; a separate Canvas demo powers the public website
- Tile-based pipe construction and parallax scrolling
- AABB collision detection with circular hitbox approximation
- Automated CI/CD for type-checking, bundle builds, CodeQL, Pages deployment, and release artifacts
Platform status
Android has a checked-in buildable Kotlin host. ReactLynx has a development web preview and standalone host. GitHub Pages runs the independent Canvas game and this manual. iOS requires a locally created Xcode project; its CI archive path is conditional and always unsigned. The Canvas demo has sound; ReactLynx native/worker audio requires a bridge implementation.
About Lynx
Lynx is an open-source cross-platform native UI framework created by ByteDance (the company behind TikTok). Open-sourced in early 2025, it allows developers to write apps in TypeScript/TSX using React-like APIs and render them as truly native UIs — not WebViews.
Why Lynx for This Project
This project exists to learn Lynx by building something real. A Flappy Bird clone is a good fit because it exercises:
- Element-based rendering — no canvas, all positioning via
<view>+ CSS transforms - Frequent state updates — a 17ms timer targeting approximately 60 updates per second
- Touch input — tap events for gameplay
- Asset loading — images, sprites, audio
- Cross-platform code — Android host and web preview, with an iOS source scaffold requiring Xcode setup
- CI/CD — automated build and release pipeline
How Lynx Differs from Other Frameworks
| Feature | Lynx | React Native | WebView (Cordova) |
|---|---|---|---|
| Rendering | Native engine on mobile | Native renderer (Fabric in the New Architecture) | Browser engine |
| UI elements | <view>, <image>, <text> etc. |
<View>, <Image>, <Text> |
HTML elements |
| Styling | CSS with platform/version-specific support | JavaScript style objects and supported layout/style properties | Web CSS |
| Execution | Main-thread rendering + background JavaScript | New Architecture uses JSI; not the legacy serialized bridge | Browser main thread, with workers available |
| JS engine | PrimJS on these native hosts; platform-dependent runtimes elsewhere | Hermes by default | Browser-dependent (for example V8 or JavaScriptCore) |
| Component APIs | ReactLynx, with its own runtime and compatibility APIs | React Native | React DOM |
| Build tool | Rspack (@lynx-js/rspeedy) |
Metro | Webpack/Vite |
Key Lynx Concepts Used in Speedy Bird
React Native 0.82 and newer run only on the New Architecture. Framework behavior evolves; consult the linked official references instead of treating this table as a benchmark or exhaustive feature list.
Elements Used Here
The ReactLynx game uses three built-in elements:
<view>— every container, positioned absolutely with transforms<image>— bird sprites, pipe tiles, background, ground, medals, digits<text>— score numbers on the game-over panel
This application does not use canvas or extended elements for its Lynx renderer. Lynx and its platform extensions offer additional elements such as video, SVG, and canvas integrations; availability depends on the platform and registered components. The Pages demo uses the browser’s standard Canvas and Audio APIs.
CSS Differences
- Layout and styling are interpreted by Lynx on native targets, not by a browser stylesheet engine
- The game uses explicit absolute positioning, flex rows, overflow clipping, and transforms
- Length units include
px,ppx,rpx,em,rem,vh, andvw; percentages are supported where the property permits them
The project primarily uses pixels and percentages. See the Lynx length reference for definitions and platform compatibility.
Dual-Threaded Architecture
React reconciliation (diffing, state updates) runs on a background thread. The main thread handles native rendering and touch events. This means:
- The game loop and React state live on the background thread
- Touch events (
bindtap) are serialized from main to background thread - Native element updates are applied on the main thread after reconciliation
Native Modules
The current audio.ts adapter detects whether the JavaScript environment exposes Audio:
- Browser contexts with
Audio: usesHTMLAudioElement; playback can be blocked until user interaction - Native and worker contexts without
Audio: uses a placeholder adapter and continues without sound; a supported native-module bridge is not implemented
The placeholder’s optional __lynx_requireModule lookup is not a documented integration contract. Future audio work should use Lynx’s supported background-thread NativeModules API and register the corresponding Android/iOS modules. The only current adapter method is play(sound); there is no native preload method. The standalone Canvas demo runs in the browser document and has its own working audio implementation.
Resources
Architecture
Project Structure
speedy-bird-lynx/
├── src/ # Lynx/ReactLynx application
│ ├── index.tsx # Entry point
│ ├── App.tsx # Root component
│ ├── types.ts # TypeScript types (GameState, PipeData)
│ ├── constants.ts # All game constants
│ ├── hooks/
│ │ └── useGameEngine.ts # Core game loop and physics
│ ├── components/
│ │ ├── Bird.tsx # Animated bird sprite
│ │ ├── Pipe.tsx # Tile-based pipe rendering
│ │ ├── Background.tsx # Parallax scrolling background
│ │ ├── Ground.tsx # Scrolling ground layer
│ │ ├── ScoreDisplay.tsx # Sprite-based digit rendering
│ │ ├── GetReadyScreen.tsx # Start screen overlay
│ │ └── GameOverScreen.tsx # Game over panel with medals
│ └── audio/
│ └── audio.ts # Audio abstraction (web + native)
│
├── android/ # Native Android host app
├── ios/ # Native iOS host app (source files)
├── assets/ # Sprites, audio, medals, digits
├── docs/ # Astro GitHub Pages site + playable canvas demo
├── web-host/ # Advanced/dev-only standalone <lynx-view> host
├── .github/workflows/ # CI/CD pipelines
├── lynx.config.ts # Lynx build configuration
├── rsbuild.web-host.config.ts # Web host build configuration
└── tsconfig.json # TypeScript configuration
Component Hierarchy
App
├── Background (z-index: 0, parallax scroll)
├── Pipe[] (z-index: 1, tile-based, scroll left)
├── Bird (z-index: 2, animated sprite + rotation)
├── Ground (z-index: 3, scroll matches pipe speed)
├── ScoreDisplay (z-index: 4, visible during play)
├── GetReadyScreen (z-index: 5, visible on ready)
└── GameOverScreen (z-index: 5, visible on game over)
Rendering Approach
The ReactLynx renderer in this project composes its visuals from built-in elements:
<view>— containers and positioning via CSS transforms<image>— sprites loaded as individual PNGs<text>— score display on the game over panel
Game entities use absolute positioning and CSS transforms for movement. The background and ground have fixed nonzero top offsets, while pipes and the bird combine layout offsets with transforms. Transform-based movement limits layout work; animation also updates image opacity and render state.
Dual-Threaded Model
Lynx runs on two threads:
| Thread | Responsibility |
|---|---|
| Background | React reconciliation, game logic, state management |
| Main | Native rendering, layout, touch event delivery |
The game loop (setInterval at 17ms) runs on the background thread. It updates a React state object (RenderState) which triggers reconciliation. Lynx’s main thread then applies the resulting native element updates.
State Management
The useGameEngine hook manages all game state:
engine.current— mutable ref holding physics state (position, velocity, pipe list). Updated every tick without triggering renders.renderState— React state snapshot pushed to components viasetRenderState()at the end of each tick and immediately after input handling.
This separation keeps mutable simulation data out of React state. Each tick still allocates a render snapshot and a shallow pipe-array copy; it is not an allocation-free game loop.
Web Rendering Surfaces
| Surface | Source | Purpose |
|---|---|---|
| ReactLynx web preview | bun run dev and http://localhost:3000/__web_preview?casename=main.web.bundle |
Development preview of the compiled main.web.bundle |
| GitHub Pages canvas demo | docs/src/pages/index.astro |
Public playable browser demo; it ports the game state machine and physics to an inline <canvas> script for zero-dependency Pages playback |
| Standalone web host | bun run dev:web-host, with bun run dev in a second terminal |
Development-only <lynx-view> host on port 4000, loading the bundle from port 3000 |
The canvas demo intentionally uses a 400x600 viewport to fit the landing-page phone frame. Core physics values such as flap force, gravity, pipe gap, speed ramp, and medal thresholds mirror src/constants.ts; the viewport height is adapted from the ReactLynx game’s 400x750 canvas height.
Game Engine
The game engine lives in src/hooks/useGameEngine.ts. It handles the game loop, physics, collision detection, scoring, and state transitions.
State Machine
The game has three states:
STATE_READY (0) ──tap──> STATE_PLAY (1) ──collision──> STATE_OVER (2)
^ │
└──────────────────────tap─────────────────────────────┘
| State | Bird | Pipes | Input |
|---|---|---|---|
| Ready | Hovers at Y=280, wing animation (20-frame interval) | None on screen | Tap starts game |
| Play | Falls with gravity, flaps on tap, fast animation (4-frame interval) | Spawn, scroll left, score on pass | Tap = flap |
| Over | Continues falling until ground contact; frame/rotation follow the same falling logic, then clamp on ground | Frozen | Tap resets to Ready |
Game Loop
The loop runs via setInterval(tick, 17) targeting ~60 FPS. Each tick:
- Update bird — apply gravity, update velocity, clamp position, compute rotation, advance animation frame
- Update pipes — spawn new pipes on interval, move all pipes left, despawn off-screen pipes (incrementing score), check collisions
- Update scenery — scroll background and ground at their respective speeds
- Push render state — call
setRenderState()with the current snapshot for React to render
Physics
Values below are per simulation tick. The 17ms timer targets about 58.8 ticks per second; browser/device scheduling can delay it, and the engine does not compensate using elapsed time.
| Parameter | Value | Effect |
|---|---|---|
| Gravity | 0.28 px/frame² | Downward acceleration |
| Flap velocity | -7.25 px/frame | Upward impulse on tap |
| Pipe base speed | 2.7 px/frame | Horizontal scroll speed |
| Speed scaling | +1% per pipe | speed = 2.7 * (1 + score * 0.01) |
| Background scroll | 0.2 px/tick base | Multiplied by 1 + score * 0.01 during play |
| Ground scroll | 2.7 px/tick base | Uses the same multiplier as pipe speed |
Bird Rotation
Rotation is determined by vertical velocity:
| Velocity | Rotation | Visual |
|---|---|---|
<= BIRD_FLAP (7.25) |
-15 deg | Nose up |
Between BIRD_FLAP and BIRD_FLAP + 2 (7.25–9.25) |
0 deg | Level |
>= BIRD_FLAP + 2 (9.25) |
70 deg | Nose dive |
During nose dive, the animation frame is set to frame 1 (wings level). A flap starts with velocity -BIRD_FLAP, so the bird stays nose-up until gravity increases velocity past the positive threshold.
Bird Animation
The bird has a 4-frame cycle: bird-0, bird-1, bird-2, bird-1 (ping-pong).
- Ready state: frame advances every 20 ticks (slow flutter)
- Play state: frame advances every 4 ticks (fast flapping)
- Over state: after pipe collision, the same falling/rotation branch continues while pipes stay frozen; ground collision clamps the bird to frame 2 with nose-dive rotation
Collision Detection
Collision uses AABB (Axis-Aligned Bounding Box) with a circular bird hitbox approximated as a square:
Bird bounding box:
left: BIRD_X - BIRD_RADIUS (80 - 12 = 68)
right: BIRD_X + BIRD_RADIUS (80 + 12 = 92)
top: birdY - BIRD_RADIUS
bottom: birdY + BIRD_RADIUS
Per pipe pair, two checks:
Top pipe: x to x+55, y to y+300
Bottom pipe: x to x+55, y+300+150 to y+600+150
Ground collision triggers when birdY + BIRD_H/2 >= CANVAS_HEIGHT - GROUND_H.
Pipe Spawning
- Interval: every 77 frames (~1.3s), adjusted by speed:
Math.max(20, Math.round(77 / speedMultiplier)) - Y position: random between -200 and -80 (controls gap vertical placement)
- Gap size: 150px fixed
- Despawn: when
pipe.x < -55(fully off-screen left), score increments
Scoring and Medals
Score increments by 1 each time a pipe scrolls off-screen. Best score persists within the session.
| Score | Medal |
|---|---|
| 10+ | Bronze |
| 25+ | Silver |
| 50+ | Gold |
| 100+ | Platinum |
Audio
Five sound effects, managed by src/audio/audio.ts:
| Event | Sound | File |
|---|---|---|
| Tap/flap | Wing flap | sfx_wing.wav |
| Pipe passed | Score point | sfx_point.wav |
| Pipe collision | Hit | sfx_hit.wav |
| Ground collision | Fall | sfx_die.wav |
| Reset game | Swoosh | sfx_swooshing.wav |
The adapter uses HTMLAudioElement only when Audio is available. Native and web-worker runtimes use an unimplemented module placeholder and remain silent. The Pages Canvas implementation has a separate browser audio path; playback follows browser user-interaction policies.
Assets and Sprites
Game sprites are pre-sliced individual PNG files. The ReactLynx renderer uses a separate <image> element for each visual rather than slicing a sprite sheet at runtime.
Directory Structure
assets/
├── sprites/
│ ├── bird-0.png # Wings down
│ ├── bird-1.png # Wings level
│ ├── bird-2.png # Wings up
│ ├── background.png # Sky + city background tile (276x228)
│ ├── ground.png # Ground tile (224x112 source, drawn at 224x129)
│ ├── get-ready.png # "Get Ready" overlay (174x160)
│ ├── game-over.png # Game over panel with score boxes (226x158)
│ ├── pipes/
│ │ ├── pipe-top.png # Top pipe body tile
│ │ ├── pipe-top-mouth.png # Top pipe mouth (lip)
│ │ ├── pipe-bottom.png # Bottom pipe body tile
│ │ └── pipe-bottom-mouth.png # Bottom pipe mouth (lip)
│ ├── digits/
│ │ └── digit-0.png … digit-9.png # Score display (18x27 each)
│ └── medals/
│ ├── medal-bronze.png
│ ├── medal-silver.png
│ ├── medal-gold.png
│ └── medal-platinum.png
│
└── audio/
├── sfx_wing.wav # Flap
├── sfx_point.wav # Score
├── sfx_hit.wav # Pipe collision
├── sfx_die.wav # Ground collision
└── sfx_swooshing.wav # Game reset
How Assets Are Loaded
In the Lynx app, assets are loaded via top-level import statements at the module level:
import bird0 from '../../assets/sprites/bird-0.png';
import bird1 from '../../assets/sprites/bird-1.png';
import bird2 from '../../assets/sprites/bird-2.png';
const BIRD_SPRITES = [bird0, bird1, bird2, bird1]; // ping-pong cycle
lynx.config.ts sets an image inline limit of 64 KiB, large enough for every current game sprite. Rspeedy embeds those images as data URLs so native hosts can load the single bundle offline. Audio imports are emitted as separate WAV assets; native audio remains a placeholder rather than a packaged playback implementation.
Pipe Rendering
Pipes use a tile-based approach instead of stretching a single image. Each pipe is composed of:
- Body tiles — repeated vertically to fill the pipe height (extends well past the screen edge)
- Mouth tile — placed at the opening where the bird flies through
This avoids visual stretching artifacts and matches the original game’s pixel-art style. Each tile is 55px wide and ~53px tall.
Background and Ground Tiling
Both the background and ground use 5 copies laid out horizontally in a flex row. The container is translated left via CSS transform. Background offset wraps at one tile width (276px); ground offset wraps at half its tile width (112px), matching the repeated ground pattern.
- Background: 5 tiles at 276px each = 1380px total, scrolls at
0.2 * speedMultiplierpx/frame - Ground: 5 tiles at 224px each = 1120px total, scrolls at
2.7 * speedMultiplierpx/frame
Score Display
The in-game score uses sprite-based digit rendering (ScoreDisplay.tsx). Each digit is drawn in an 18x27px <image> element with 2px gaps, centered on screen. These are display dimensions, not the source PNG dimensions (for example, digit-0.png is 12x18px).
The game-over panel score uses <text> elements positioned absolutely over the panel sprite.
Credits
- Sprites from The Spriters Resource
- Sound effects from The Sounds Resource
- Original Flappy Bird by Dong Nguyen
- Earlier Canvas recreation by noanonoa, preserved in this repository’s history
Website copies and social assets
The Canvas demo serves copies from docs/public/assets/. Keep each sprite and sound byte-identical to its canonical counterpart in root assets/ when updating assets. The docs build copies public files unchanged; it does not synchronize those two trees automatically.
Social previews and icon fallbacks are generated by docs/scripts/generate_social_assets.py. It uses the Pillow version pinned in docs/scripts/requirements.txt, accepts display/monospace font paths, and generates a 1200x630 og-image.png. See docs/README.md for commands and platform-specific font examples.
Getting Started
Prerequisites
- Bun for dependency installation and root scripts
- Node.js >=22.12 for the build toolchains; use npm scripts to run Astro
- Java 21, Android SDK Platform 37.2, and current Android command-line tools (minimum supported device API remains 21)
- Xcode, Ruby >=3.2, and Bundler (for iOS after creating a project from the source scaffold)
See Dependencies and Upgrades for exact versions and compatibility holds.
Quick Start
git clone https://github.com/jonathanperis/speedy-bird-lynx.git
cd speedy-bird-lynx
bun install --frozen-lockfile
bun run dev
This starts the Rspeedy dev server with hot module replacement. The game is accessible at:
- Web preview:
http://localhost:3000/__web_preview?casename=main.web.bundle - Lynx bundle:
http://localhost:3000/main.lynx.bundle
To view on a mobile device, open the Lynx bundle URL in Lynx Explorer or Lynx Go (replace localhost with your machine’s IP).
Building for Production
bun run build
This outputs:
dist/main.lynx.bundle— native bundle for Android/iOSdist/main.web.bundle— web bundle
Android
bun run build
cd android && ./gradlew assembleDebug
The APK is at android/app/build/outputs/apk/debug/app-debug.apk. Gradle stages the current root bundle into generated assets; no manual copy is needed. Install via adb install or transfer to your device. Run ./gradlew lintDebug for Android API checks, or ./gradlew assembleRelease for a release APK.
For release builds with signing, see CI/CD Pipeline.
iOS
Requires Xcode and an Xcode project (
.xcodeproj). The repository includes the Swift/CocoaPods source scaffold, but the Xcode project/workspace must be created locally before building. See Native Host Apps for setup instructions.
bun run build
cp dist/main.lynx.bundle ios/SpeedyBird/Resources/
cd ios
bundle install
bundle exec pod install
open SpeedyBird.xcworkspace
Configure the project, scheme, resources, and deployment target in Xcode, then build for your selected simulator or device. Device distribution requires signing configuration; the checked-in CI commands always produce unsigned archives when a project exists.
Web / GitHub Pages
The public web surface is the Astro site in docs/. It renders the landing page, embeds the playable canvas demo, and generates wiki pages from docs/wiki/*.md. Astro 7 requires Node.js >=22.12, so use the npm scripts for the docs dev server/build even though dependencies are installed from bun.lock.
cd docs
bun install --frozen-lockfile
npm run dev
For a production build:
npm run build
npm run check:site
npm run preview
The static output is written to docs/out/. Development serves / and /docs/; production/preview uses /speedy-bird-lynx/ and /speedy-bird-lynx/docs/. The shared Pages workflow deploys that production output. Content authoring and asset maintenance are documented in docs/README.md.
Web Surfaces
| Surface | How to use it | Notes |
|---|---|---|
| ReactLynx web preview | bun run dev, then open http://localhost:3000/__web_preview?casename=main.web.bundle |
Uses the compiled main.web.bundle from Rspeedy for development |
| GitHub Pages canvas demo | cd docs && npm run dev, then open the local Astro URL |
Browser-only playable demo in docs/src/pages/index.astro; physics mirror the ReactLynx game, while the 400x600 viewport is adapted to the landing-page phone frame |
| Standalone web host | bun run dev:web-host at http://localhost:4000 |
Also run bun run dev at port 3000; the host loads http://localhost:3000/main.web.bundle |
The standalone host is development-only and needs two terminals. Both servers configure cross-origin isolation headers. Use the listed localhost URLs consistently; if you change the bundle server’s port, update web-host/index.html too. bun run build:web-host compiles the host but does not turn it into a self-contained Pages deployment.
Project Commands
| Command | Description |
|---|---|
bun run dev |
Start Rspeedy dev server with HMR |
bun run build |
Production build (Lynx + Web bundles) |
bun run check |
Type-check the ReactLynx app |
bun run dev:web-host |
Serve the development-only Lynx web host on port 4000 |
bun run build:web-host |
Compile the standalone host |
cd docs && npm run dev |
Start Astro docs/dev site with Node >=22.12 |
cd docs && npm run build |
Build Astro GitHub Pages output to docs/out/ with Node >=22.12 |
cd docs && npm run preview |
Preview the production docs build with Node >=22.12 |
cd docs && npm run check:site |
Validate generated routes, links, IDs, and metadata after building |
cd android && ./gradlew assembleDebug |
Build debug Android APK |
cd android && ./gradlew assembleRelease |
Build release Android APK |
cd android && ./gradlew lintDebug |
Check native Android API and resource usage |
Native Host Apps
Lynx bundles do not run standalone — they need a thin native shell that embeds the Lynx runtime and loads the bundle. This project includes a ready-to-build Android host app and iOS source files that must be added to a locally created Xcode project before building.
Android
The Android host app is a minimal Kotlin application in android/.
Key Files
| File | Purpose |
|---|---|
SpeedyBirdApplication.kt |
Initializes Lynx engine, Fresco (image loading), and registers services |
MainActivity.kt |
Creates a LynxView and loads main.lynx.bundle from assets |
AssetTemplateProvider.kt |
Implements AbsTemplateProvider to read bundles from APK assets |
AndroidManifest.xml |
Fullscreen, portrait-only, internet permission |
build.gradle.kts |
Lynx SDK 4.1.0 dependencies, generated bundle assets, and signing config from env vars |
proguard-rules.pro |
Keep rules for Lynx SDK classes during R8 minification |
Dependencies
| Artifact | Purpose |
|---|---|
org.lynxsdk.lynx:lynx |
Core rendering engine |
org.lynxsdk.lynx:lynx-jssdk |
JavaScript bridge |
org.lynxsdk.lynx:primjs |
JavaScript engine, version 4.1.1 as required by Lynx 4.1.0 |
org.lynxsdk.lynx:lynx-trace |
Performance tracing |
org.lynxsdk.lynx:lynx-service-image |
Image loading (wraps Fresco) |
org.lynxsdk.lynx:lynx-service-log |
Logging |
org.lynxsdk.lynx:lynx-service-http |
Network requests |
com.facebook.fresco:* |
Image loading and animated image support required by lynx-service-image |
com.squareup.okhttp3:okhttp |
HTTP client support for Lynx services |
com.google.code.gson:gson |
JSON (required by Lynx internals) |
How It Works
SpeedyBirdApplication.onCreate()initializes Fresco, registers Lynx services, and callsLynxEnv.inst().init()MainActivity.onCreate()builds aLynxViewviaLynxViewBuilder, attaches theAssetTemplateProvider, and callsrenderTemplateUrl("main.lynx.bundle", "")- The
AssetTemplateProviderreads the bundle bytes fromassets/main.lynx.bundleand passes them to the Lynx engine
Run bun run build before Gradle. The prepareLynxAssets task stages that exact bundle from root dist/ into android/app/build/generated/lynxAssets/. The APK assets source is this generated directory, not a manually populated source folder. Images are embedded in the bundle. After building, verify the packaged input from the repository root:
python3 scripts/verify_android_bundle.py android/app/build/outputs/apk/debug/app-debug.apk
The Android toolchain uses Java 21, AGP 9.4.0, Gradle 9.7.1, and Kotlin 2.4.20 through AGP’s built-in Kotlin integration. Compile SDK is 37.2, target SDK is 37, and minimum device API is 21. Run ./gradlew lintDebug to check API usage. See Dependencies and Upgrades for image-library compatibility holds.
Signing
The build.gradle.kts reads signing configuration from environment variables:
KEYSTORE_FILE— path to keystore fileKEYSTORE_PASSWORD— keystore passwordKEY_ALIAS— key aliasKEY_PASSWORD— key password
These are populated by CI from GitHub Secrets. For local release builds, export the same environment variables in your shell before running Gradle.
Android Artifacts
| Build path | Command/workflow | Output | Signing |
|---|---|---|---|
| Local debug | bun run build, then cd android && ./gradlew assembleDebug |
android/app/build/outputs/apk/debug/app-debug.apk |
Debug-signed by Android tooling |
| Local release | Same bundle build, then cd android && ./gradlew assembleRelease |
android/app/build/outputs/apk/release/ |
Signed only when KEYSTORE_FILE, KEYSTORE_PASSWORD, KEY_ALIAS, and KEY_PASSWORD are exported |
| CI main-build release | build-android.yml on main or manual dispatch from main |
APK artifact followed by separate immutable build-release publication | Signed only when KEYSTORE_BASE64, KEYSTORE_PASSWORD, KEY_ALIAS, and KEY_PASSWORD GitHub Secrets are configured |
| Tagged release | release.yml on v* tags |
Sole versioned-release publisher; assets uploaded before publication | Signed only when the same keystore secrets are configured |
Native Audio Status
The adapter in src/audio/audio.ts uses HTMLAudioElement only in JavaScript contexts where Audio exists. Native and worker-based web runtimes currently use a placeholder and continue without sound. The current interface is play(sound) only; there is no preload bridge method. A future implementation must replace the optional internal lookup with Lynx’s supported background-thread NativeModules API, implement and register platform modules, and package their sound resources. The separate Canvas demo already uses browser audio.
iOS
The iOS host app source files are in ios/. An Xcode project must be created manually before the app can be built or archived.
Included Files
| File | Purpose |
|---|---|
Podfile |
Lynx 4.1.0, PrimJS 4.1.1, and the image versions required by LynxService |
Gemfile / Gemfile.lock |
Reproducible CocoaPods 1.17.0 and xcodeproj 1.28.1 tooling |
AppDelegate.swift |
Initializes LynxEnv |
SceneDelegate.swift |
Creates window with ViewController |
ViewController.swift |
Fullscreen LynxView, portrait-only, hidden status bar |
BundleTemplateProvider.swift |
Loads main.lynx.bundle from the app bundle |
SpeedyBird-Bridging-Header.h |
Objective-C bridge for Lynx SDK headers |
Info.plist |
App metadata, scene configuration |
Setup Steps
- Open Xcode > File > New > Project > App
- Product Name:
SpeedyBird - Bundle Identifier:
com.jonathanperis.speedybird - Language: Swift; the verified scaffold uses Swift 5 language mode
- iOS Deployment Target: 15.0 or newer
- Product Name:
- Delete the auto-generated Swift files
- Add the files from
ios/SpeedyBird/; set the target’s Info.plist file toSpeedyBird/Info.plistinstead of generating one - Build Settings > Swift Compiler > Objective-C Bridging Header > set to
SpeedyBird/SpeedyBird-Bridging-Header.h - Build the root bundle and copy
dist/main.lynx.bundleintoios/SpeedyBird/Resources/ - With Ruby >=3.2 and Bundler:
cd ios && bundle install && bundle exec pod install - Open
SpeedyBird.xcworkspace(not.xcodeproj) - Build Phases > Copy Bundle Resources > add
main.lynx.bundle
The Podfile uses both CocoaPods trunk and the official lynx-family/Specs repository. Its image-library versions are exact upstream requirements. It sets deployment target iOS 15 for the app and pods to match Xcode 27’s supported range, and uses a native resource-copy phase so script sandboxing stays enabled. Dependency resolution, an unsigned app build, and an unsigned archive were verified in a temporary project; configure your own shared SpeedyBird scheme because that verification project is not distributed. See Dependencies and Upgrades for the scoped upstream compiler adjustments.
Apple Developer Program
| Feature | Free | Paid ($99/year) |
|---|---|---|
| Simulator builds | Yes | Yes |
| Sideload to own device | Personal Team restrictions, including short-lived provisioning | Development/ad hoc provisioning subject to Apple’s limits |
| TestFlight | No | Yes |
| App Store distribution | No | Yes |
| CI signing (certificates) | No | Yes |
Simulator builds and unsigned archives do not require paid membership. TestFlight/App Store distribution requires membership and signing configuration. The checked-in workflows always pass CODE_SIGNING_ALLOWED=NO and skip without an Xcode project. They do not import signing certificates or consume Apple signing secrets, so adding secrets alone does not enable a signed iOS release.
CI/CD Pipeline
All automation runs on GitHub Actions. Workflows are in .github/workflows/.
Workflow Overview
| Workflow | File | Trigger | Description |
|---|---|---|---|
| Build Check | ci.yml |
Manual, push to main/lynx-migration, PR to main |
Audit dependencies, check bundles/web host/docs, validate site links/metadata, compile/lint Android, verify APK bundle |
| CodeQL | codeql.yml |
Push/PR to main, weekly, manual |
JavaScript/TypeScript and Actions security analysis |
| Deploy Web | deploy.yml |
Push to main, manual |
Build and deploy the Astro docs/ site to GitHub Pages via the shared Pages workflow |
| Build Android | build-android.yml |
Push to main, manual from main |
Read-only build/signing job followed by a separate build-release publisher |
| Build iOS | build-ios.yml |
v* tags, manual |
Skip without an Xcode project; otherwise build an unsigned archive |
| Release | release.yml |
v* tags, manual |
Full release pipeline with all artifacts |
Build Check
Runs on manual dispatch, pushes to main/lynx-migration, and pull requests targeting main. Validates the codebase compiles and builds:
bun install --frozen-lockfile— install dependenciesbun audit— fail on known dependency advisoriesbun run check— TypeScript type-checkingbun run buildandbun run build:web-host— build Lynx/web bundles and the development host- Upload bundles as artifact (14-day retention)
- Compile and lint the Android debug host in a read-only job without signing secrets, then verify the APK contains the current bundle
- Install the frozen docs lockfile, audit it, build the site, and check every generated page’s local links, fragments, unique IDs, and canonical/OG URL
Gameplay unit tests, browser UI tests, and JavaScript lint/format checks are not configured. Successful builds and static checks do not establish device behavior or accessibility conformance.
Deploy Web
Deploys the Astro site in docs/ to GitHub Pages on pushes to main or manual dispatch from main. The repository calls jonathanperis/.github/.github/workflows/pages-docs-deploy.yml at the full commit SHA recorded in deploy.yml. Only the optional public analytics ID is passed as a secret. The shared workflow installs frozen dependencies, uses Node.js 22 for Astro 7, builds the docs site, and publishes the static output.
Build Android
The main production workflow. On every push to main:
- Version computation — from the short SHA (
0.0.0-a1b2c3d) - Build Lynx bundle —
bun run build - Stage bundle — Gradle copies the current
dist/main.lynx.bundleinto generated APK assets - Decode keystore when configured — from
KEYSTORE_BASE64secret - Gradle build and package verification —
./gradlew assembleRelease, signed only when the signing env vars are present; verify that the APK contains the exact current bundle - Artifact handoff — upload the versioned APK from the read-only build job
- Immutable publication — a separate write-enabled job creates
build/<version>at the verified commit, uploads the APK to a draft, then publishes it
Android Signing Secrets
| Secret | Purpose |
|---|---|
KEYSTORE_BASE64 |
Base64-encoded release keystore |
KEYSTORE_PASSWORD |
Keystore password |
KEY_ALIAS |
Key alias name |
KEY_PASSWORD |
Key password |
Android Artifact Matrix
| Artifact | Trigger/path | Signing/status |
|---|---|---|
| Local debug APK | cd android && ./gradlew assembleDebug after the root bundle build |
Debug-signed by Android tooling |
| CI main-build APK | build-android.yml on push to main or manual dispatch |
Release build; signed only when keystore secrets are configured |
| Tagged release APK | release.yml on v* tags |
Attached to the GitHub Release; signed when KEYSTORE_BASE64, KEYSTORE_PASSWORD, KEY_ALIAS, and KEY_PASSWORD are present |
Versioning
| Trigger | Version Name | Version Code | Release Type |
|---|---|---|---|
Push to main |
0.0.0-<sha> |
Epoch-based | Automated build release (build/<version> tag) |
Tag v1.2.3 |
1.2.3 |
Epoch-based | Full release |
Build iOS
Scaffolded but requires manual setup:
- Create and track an Xcode project with a shared
SpeedyBirdscheme (see Native Host Apps). - Build/package the bundle and install the locked Ruby/CocoaPods dependencies.
- Build an unsigned archive. Paid membership is not needed for this operation.
The checked-in ios/ directory contains a Swift/CocoaPods scaffold, not a generated .xcodeproj. The workflows always disable code signing; Apple secrets are not read. Signed device distribution would require additional workflow implementation as well as Apple signing assets. Dependency resolution alone is not an app/archive build.
Release
Triggered by version tags (v*) or manual dispatch from main or a version tag. This is the sole publisher for versioned releases, avoiding concurrent publishers racing an immutable release. Builds Lynx bundles and conditionally builds Android/iOS when their native projects exist; a failed build blocks publication. The publishing job uploads all available assets to a draft, then publishes the immutable release. Subsequent corrections require a new release instead of replacing published assets.
Dependencies and Upgrades
Last reviewed: 2026-09-17. Versions below describe this checkout, not a promise that every upstream package will remain at its latest release. The manifests and lockfiles are the source of truth.
JavaScript toolchains
| Component | Version | Role |
|---|---|---|
| ReactLynx | 0.126.1 | Game components and hooks |
| ReactLynx Rsbuild plugin | 0.20.2 | Coordinated compiler/runtime integration |
| Rspeedy | 0.17.2 | Native and web bundle builds; uses Rsbuild 2.2.4 internally |
| Rsbuild | 2.2.7 | Standalone web host |
| Lynx web core / elements | 0.26.1 / 0.12.11 | Browser runtime |
| Lynx core | 0.1.4 | Explicit runtime peer required by the standalone web host |
| React / React DOM / React types | 19.3.0 | Compatibility dependencies and typings; the game imports ReactLynx |
| Lynx TypeScript bindings | 4.2.1 | API declarations; host support must still be checked against the native SDK |
| App TypeScript | 6.0.3 | Latest release within Rspeedy’s supported peer range |
| Docs TypeScript | 7.0.2 | Independent documentation toolchain |
| Astro | 7.3.3 | Static website and Rust-powered Markdown rendering |
| Tailwind CSS / Vite plugin | 4.3.3 | Documentation styling |
The ReactLynx, compiler plugin, and Rspeedy versions must be upgraded together. ReactLynx 0.126 moves to Preact 11 internally: effect cleanup on component removal is deferred until the after-paint flush; page destruction still drains cleanup synchronously. This app mounts one root game engine and cleans up its timer in its effect cleanup.
React 19 typings require jsxImportSource: "@lynx-js/react" so <view> and <image> are typed as Lynx elements. Web core now exports its browser entry at @lynx-js/web-core/client. Rspeedy 0.17 uses .lynx intermediate directories. These migrations are applied in this checkout.
The standalone host also requires @lynx-js/lynx-core 0.1.4 explicitly: web core marks it as an optional peer, but its background-thread loader imports @lynx-js/lynx-core/web. A previously populated node_modules directory can hide this missing declaration; the clean CI install verifies it is reproducible.
Native toolchains and compatibility holds
| Component | Version / requirement | Compatibility note |
|---|---|---|
| Lynx Android/iOS SDK | 4.1.0 | Latest stable native release checked |
| PrimJS | 4.1.1 | Required by Lynx 4.1.0; not the same version as the SDK |
| Android Gradle Plugin | 9.4.0 | Uses built-in Kotlin support |
| Gradle | 9.7.1 | Wrapper distribution is checksum-pinned |
| Kotlin Gradle plugin | 2.4.20 | Supplies the compiler used by AGP’s built-in integration |
| Java | 21 recommended | Used by the verified local Android build and CI |
| Android SDK | compile 37.2, target 37, minimum 21 | OkHttp 5.5 requires compile API 37 or newer |
| OkHttp / Gson | 5.5.0 / 2.14.0 | Native HTTP and JSON support |
| Fresco family | 3.7.0 | Coordinated image modules; Android debug/release and API lint checked with Lynx 4.1.0 |
| SDWebImage / WebP coder | 5.15.5 / 0.11.0 | Exact dependencies in the LynxService/Image 4.1.0 podspec |
TypeScript 7.0.2 is not supported by Rspeedy 0.17.2, whose declared peer range ends at 6.0.x. The root stays on ~6.0.3; the independent docs package can use TypeScript 7. Do not force an unsupported peer range to make a version table look newer.
Fresco was upgraded to 3.7.0 after Android compilation, R8 release processing, and API lint passed; its older native-library page-alignment warnings were eliminated. Lynx’s image service was compiled upstream against Fresco 2.3.0, so device-level rendering remains part of future runtime verification. SDWebImage 5.21.7 and SDWebImageWebPCoder 0.15.0 were available, but the Podfile retains the exact older versions required by LynxService/Image 4.1.0. Unused XElement integrations were removed: the game uses only built-in view, image, and text elements.
iOS remains a source scaffold: this repository does not track an Xcode project. Dependency resolution, an unsigned app build, and an unsigned archive were verified in a temporary Xcode 27 project targeting iOS 15. Both artifacts contain the current game bundle and Lynx resources. This does not establish a checked-in project, device installation, signing, or native audio. See Native Host Apps.
The Podfile aligns older pod deployment targets to iOS 15. For the Lynx target only, Xcode 27’s unused-result/deprecation diagnostics remain warnings instead of being promoted to errors by upstream flags. Its one pinned runtime resource bundle is copied through Xcode’s native resource phase, keeping user-script sandboxing enabled. Revisit these narrowly scoped integration adjustments when upgrading Lynx or CocoaPods.
Upgrade checklist
- Read current stable release notes and peer dependencies. Check native POMs/podspecs as well as npm versions.
- Update compatible packages and regenerate both Bun lockfiles. Re-run frozen installs to prove reproducibility.
- Run
bun run check,bun run build, andbun run build:web-hostat the repository root, thenbun audit. - In
docs/, runnpm run build,npm run check:site, andbun auditusing Node.js >=22.12. - After the bundle build, run
./gradlew assembleDebug assembleRelease lintDebuginandroid/. Inspect signing status rather than assuming a release APK is signed. - Resolve the iOS pods and build from a configured Xcode project when available. Report compilation, signing, simulator, and device evidence separately.
- Update this page, setup instructions, workflow pins, and the platform matrix. Keep historical migration plans marked as historical.