Speedy Bird Manual

Builder's Manual · Night Arcade Cabinet

Speedy Bird Service Manual

The technical docs now sit inside the same arcade world as the game: a service panel for the speed curve, ReactLynx architecture, sprite engine, native hosts, and release pipeline.

8Manual pages
+1%Speed / pipe
3Build targets

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 What Lynx is, how it differs from React Native and WebViews, and key concepts used in this project
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 and iOS native shells that embed the Lynx runtime

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 rendering with CSS transforms (no canvas)
  • 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

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 — 60 FPS game loop pushing React state
  • Touch input — tap events for gameplay
  • Asset loading — images, sprites, audio
  • Cross-platform builds — same code on web, Android, iOS
  • CI/CD — automated build and release pipeline

How Lynx Differs from Other Frameworks

Feature Lynx React Native WebView (Cordova)
Rendering Native C++ engine Native bridge Browser engine
UI elements <view>, <image>, <text> etc. <View>, <Image>, <Text> HTML elements
Styling CSS (200+ properties) StyleSheet (CSS subset) Full CSS
Threading Dual (background + main) Bridge-based Single
JS engine QuickJS (PrimJS) Hermes/JSC V8/JSC
React compat React 17+ (ReactLynx) React Native React DOM
Build tool Rspack (@lynx-js/rspeedy) Metro Webpack/Vite

Key Lynx Concepts Used in Speedy Bird

Built-in Elements

Lynx only has ~10 built-in elements. This game uses:

  • <view> — every container, positioned absolutely with transforms
  • <image> — bird sprites, pipe tiles, background, ground, medals, digits
  • <text> — score numbers on the game-over panel

There is no <canvas>, <svg>, <audio>, or <video>.

CSS Differences

  • Default box-sizing: border-box and position: relative
  • No margin collapsing, no inline elements
  • display values: linear (default), flex, grid, none
  • overflow only supports visible and hidden
  • Units: px, %, vh — no em, rem, vw

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

Lynx does not have built-in audio. The audio.ts module detects the environment:

  • Web: uses HTMLAudioElement (works immediately)
  • Native: calls a native module via requireModule('AudioModule') (requires per-platform 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

Lynx has no canvas element. All visuals are composed 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

Every game entity is absolutely positioned at top: 0, left: 0 and moved using CSS transform: translate(Xpx, Ypx). This avoids layout recalculations — the engine only updates transform strings.

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(). Only updated at the end of each tick.

This separation means physics calculations do not allocate React objects — only the final render snapshot does.

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 web-host/ with rsbuild.web-host.config.ts Advanced/dev-only host that renders main.web.bundle inside <lynx-view>; web-host/index.html currently points at the configured bundle URL

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

All values are in pixels per frame (at 60 FPS):

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/frame Slower parallax layer
Ground scroll 2.7 px/frame Matches 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.259.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

On web, audio uses HTMLAudioElement. On native platforms, it falls back to a native module stub (AudioModule).

Assets and Sprites

All game assets are pre-sliced individual PNG files. Lynx has no canvas or sprite sheet slicing — each visual element is a separate <image> element.

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 (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

The Rspeedy bundler resolves these to URLs (dev server) or embeds them in the bundle (production).

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 to create seamless scrolling. When the offset exceeds one tile width, it wraps back.

  • 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 a separate <image> element (18x27px) laid out horizontally with 2px gaps, centered on screen.

The game-over panel score uses <text> elements positioned absolutely over the panel sprite.

Credits

Getting Started

Prerequisites

  • Bun for the root ReactLynx/Rspeedy app (bun install, bun run dev, bun run build)
  • Node.js >=22.12 for the Astro 7 documentation site in docs/ (npm run dev/build/preview)
  • Java 17 and Android SDK (for Android builds)
  • Xcode 15+ and CocoaPods (for iOS builds after creating the Xcode project from the included source scaffold)

Quick Start

git clone https://github.com/jonathanperis/speedy-bird-lynx.git
cd speedy-bird-lynx
bun install
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
cp dist/main.lynx.bundle android/app/src/main/assets/
cd android && ./gradlew assembleDebug

The APK is at android/app/build/outputs/apk/debug/app-debug.apk. Install via adb install or transfer to your device.

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 && pod install
open SpeedyBird.xcworkspace

Build and run from Xcode on a simulator or device.

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
npm run dev

For a production build:

npm run build
npm run preview

The static output is written to docs/out/ and is deployed to GitHub Pages by the shared Pages workflow.

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 Advanced/dev-only web-host/ + rsbuild.web-host.config.ts path Renders main.web.bundle inside <lynx-view> and currently expects the bundle URL configured in web-host/index.html

Project Commands

Command Description
bun run dev Start Rspeedy dev server with HMR
bun run build Production build (Lynx + Web bundles)
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 android && ./gradlew assembleDebug Build debug Android APK
cd android && ./gradlew assembleRelease Build release Android APK

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 3.7.0 dependencies, 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 QuickJS JavaScript engine
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
org.lynxsdk.lynx:xelement Extended UI elements
org.lynxsdk.lynx:xelement-input Extended input elements used by the Android Lynx runtime
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

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, copy dist/main.lynx.bundle into android/app/src/main/assets/, then cd android && ./gradlew assembleDebug android/app/build/outputs/apk/debug/app-debug.apk Debug-signed by Android tooling
Local release Same bundle copy, 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 release build-android.yml on main, v* tags, or manual dispatch APK artifact and GitHub Release attachment Signed only when KEYSTORE_BASE64, KEYSTORE_PASSWORD, KEY_ALIAS, and KEY_PASSWORD GitHub Secrets are configured

Native Audio Status

The game audio abstraction in src/audio/audio.ts uses HTMLAudioElement on web. Native builds currently have stubs: if an Android or iOS AudioModule is not implemented and registered with the Lynx bridge, the game logs a warning and continues without sound. To enable native sound effects, implement the platform-specific module, expose the same play/preload contract used by the web implementation, and register it during app startup.

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 3.7.0, PrimJS, SDWebImage, XElement
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
  2. Delete the auto-generated Swift files
  3. Add all files from ios/SpeedyBird/ to the project
  4. Build Settings > Swift Compiler > Objective-C Bridging Header > set to SpeedyBird/SpeedyBird-Bridging-Header.h
  5. Build Settings > User Script Sandboxing > set to NO
  6. Terminal: cd ios && pod install
  7. Open SpeedyBird.xcworkspace (not .xcodeproj)
  8. Build Phases > Copy Bundle Resources > add main.lynx.bundle

Apple Developer Program

Feature Free Paid ($99/year)
Simulator builds Yes Yes
Sideload to own device 7-day expiry Unlimited
TestFlight No Yes
App Store distribution No Yes
CI signing (certificates) No Yes

Without an Apple Developer Program configuration, CI/local workflows can only produce unsigned simulator or archive outputs after the Xcode project exists. TestFlight/App Store distribution requires signing certificates, provisioning profiles, and workflow secrets.

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 Type-check and build Lynx bundles; no unit-test framework is configured yet
CodeQL codeql.yml Push/PR to main, weekly Security and quality 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, v* tags, manual Build release APK, sign when secrets are configured, create GitHub Release
Build iOS build-ios.yml v* tags, manual Build iOS archive (unsigned)
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. bunx tsc --noEmit — TypeScript type-checking
  3. bun run build — build Lynx and web bundles
  4. Upload bundles as artifact (14-day retention)

Current quality gates are TypeScript type-checking, production bundle builds, CodeQL, and Pages deployment. Unit tests, browser smoke tests, and lint/format checks are not configured yet.

Deploy Web

Deploys the Astro site in docs/ to GitHub Pages on pushes to main or manual dispatch. The repository delegates deployment to the shared reusable workflow jonathanperis/.github/.github/workflows/pages-docs-deploy.yml@main; that shared workflow installs 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 git tag (v1.2.31.2.3) or short SHA (0.0.0-a1b2c3d)
  2. Build Lynx bundlebun run build
  3. Copy bundle — into android/app/src/main/assets/
  4. Decode keystore when configured — from KEYSTORE_BASE64 secret
  5. Gradle build./gradlew assembleRelease, signed only when the signing env vars are present
  6. Git tag — creates build/<version> tag on main pushes
  7. GitHub Release — creates a release with APK attached

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 copying dist/main.lynx.bundle into assets 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 build-android.yml or 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 Xcode project (see Native Host Apps)
  2. Enroll in Apple Developer Program ($99/year)
  3. Configure signing secrets

Currently builds an unsigned archive only after an Xcode project exists. The checked-in ios/ directory contains Swift/CocoaPods source files, not a generated .xcodeproj; local developers must create the Xcode project and add the source files before building. TestFlight/App Store distribution requires Apple Developer Program signing assets and workflow secrets. See the workflow file header for detailed setup instructions.

Release

Triggered by version tags (v*) or manual dispatch. Builds Lynx bundles and conditionally builds Android/iOS if their native projects exist. Creates a GitHub Release with all available artifacts and auto-generated release notes.