Speedy Bird Manual

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.

9Manual pages
+1%Speed / pipe
iOSSource scaffold

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.


GitHub · Play → · Jonathan Peris

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, and vw; 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: uses HTMLAudioElement; 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 via setRenderState() 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:

  1. Update bird — apply gravity, update velocity, clamp position, compute rotation, advance animation frame
  2. Update pipes — spawn new pipes on interval, move all pipes left, despawn off-screen pipes (incrementing score), check collisions
  3. Update scenery — scroll background and ground at their respective speeds
  4. 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:

  1. Body tiles — repeated vertically to fill the pipe height (extends well past the screen edge)
  2. 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 * speedMultiplier px/frame
  • Ground: 5 tiles at 224px each = 1120px total, scrolls at 2.7 * speedMultiplier px/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

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/iOS
  • dist/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

  1. SpeedyBirdApplication.onCreate() initializes Fresco, registers Lynx services, and calls LynxEnv.inst().init()
  2. MainActivity.onCreate() builds a LynxView via LynxViewBuilder, attaches the AssetTemplateProvider, and calls renderTemplateUrl("main.lynx.bundle", "")
  3. The AssetTemplateProvider reads the bundle bytes from assets/main.lynx.bundle and 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 file
  • KEYSTORE_PASSWORD — keystore password
  • KEY_ALIAS — key alias
  • KEY_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

  1. 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
  2. Delete the auto-generated Swift files
  3. Add the files from ios/SpeedyBird/; set the target’s Info.plist file to SpeedyBird/Info.plist instead of generating one
  4. Build Settings > Swift Compiler > Objective-C Bridging Header > set to SpeedyBird/SpeedyBird-Bridging-Header.h
  5. Build the root bundle and copy dist/main.lynx.bundle into ios/SpeedyBird/Resources/
  6. With Ruby >=3.2 and Bundler: cd ios && bundle install && bundle exec pod install
  7. Open SpeedyBird.xcworkspace (not .xcodeproj)
  8. 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:

  1. bun install --frozen-lockfile — install dependencies
  2. bun audit — fail on known dependency advisories
  3. bun run check — TypeScript type-checking
  4. bun run build and bun run build:web-host — build Lynx/web bundles and the development host
  5. Upload bundles as artifact (14-day retention)
  6. Compile and lint the Android debug host in a read-only job without signing secrets, then verify the APK contains the current bundle
  7. 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:

  1. Version computation — from the short SHA (0.0.0-a1b2c3d)
  2. Build Lynx bundle — bun run build
  3. Stage bundle — Gradle copies the current dist/main.lynx.bundle into generated APK assets
  4. Decode keystore when configured — from KEYSTORE_BASE64 secret
  5. 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
  6. Artifact handoff — upload the versioned APK from the read-only build job
  7. 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:

  1. Create and track an Xcode project with a shared SpeedyBird scheme (see Native Host Apps).
  2. Build/package the bundle and install the locked Ruby/CocoaPods dependencies.
  3. 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

  1. Read current stable release notes and peer dependencies. Check native POMs/podspecs as well as npm versions.
  2. Update compatible packages and regenerate both Bun lockfiles. Re-run frozen installs to prove reproducibility.
  3. Run bun run check, bun run build, and bun run build:web-host at the repository root, then bun audit.
  4. In docs/, run npm run build, npm run check:site, and bun audit using Node.js >=22.12.
  5. After the bundle build, run ./gradlew assembleDebug assembleRelease lintDebug in android/. Inspect signing status rather than assuming a release APK is signed.
  6. Resolve the iOS pods and build from a configured Xcode project when available. Report compilation, signing, simulator, and device evidence separately.
  7. Update this page, setup instructions, workflow pins, and the platform matrix. Keep historical migration plans marked as historical.

Sources