Home
Speedy Bird Lynx
Flappy Bird clone built with ReactLynx and TypeScript. Runs natively on iOS, Android, and Web from a single codebase using Lynx's native C++ rendering engine and dual-threaded architecture.
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 or press Space to flap; fly through pipe gaps to score
- 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 all three platforms
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-boxandposition: relative - No margin collapsing, no inline elements
displayvalues:linear(default),flex,grid,noneoverflowonly supportsvisibleandhidden- Units:
px,%,vh— noem,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/ # GitHub Pages (standalone canvas version)
├── web-host/ # Web preview host (dev only)
├── .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 viasetRenderState(). Only updated at the end of each tick.
This separation means physics calculations do not allocate React objects — only the final render snapshot does.
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 require() at the module level:
const BIRD_SPRITES = [
require('../../assets/sprites/bird-0.png'),
require('../../assets/sprites/bird-1.png'),
require('../../assets/sprites/bird-2.png'),
require('../../assets/sprites/bird-1.png'), // 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:
- 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 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 * 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 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
- Sprites from The Spriters Resource
- Sound effects from The Sounds Resource
- Original game by Dong Nguyen
CI/CD Pipeline
All automation runs on GitHub Actions. Workflows are in .github/workflows/.
Workflow Overview
| Workflow | File | Trigger | Description |
|---|---|---|---|
| Build Check | ci.yml |
Push/PR to main |
Type-check and build Lynx bundles |
| CodeQL | codeql.yml |
Push/PR to main, weekly |
Security and quality analysis |
| Deploy Web | deploy.yml |
Push to main |
Deploy docs/ to GitHub Pages |
| Build Android | build-android.yml |
Push to main, v* tags, manual |
Build signed APK, 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 every push and pull request. Validates the codebase compiles and builds:
npm ci— install dependenciesnpx tsc --noEmit— TypeScript type-checkingnpm run build— build Lynx and web bundles- Upload bundles as artifact (14-day retention)
Deploy Web
Deploys the docs/ directory (standalone canvas game) to GitHub Pages on every push to main. Uses the GitHub Actions Pages deployment model (actions/deploy-pages).
Build Android
The main production workflow. On every push to main:
- Version computation — from git tag (
v1.2.3→1.2.3) or short SHA (0.0.0-a1b2c3d) - Build Lynx bundle —
npm run build - Copy bundle — into
android/app/src/main/assets/ - Decode keystore — from
KEYSTORE_BASE64secret - Gradle build —
./gradlew assembleReleasewith signing env vars - Git tag — creates
build/<version>tag onmainpushes - 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 |
Versioning
| Trigger | Version Name | Version Code | Release Type |
|---|---|---|---|
Push to main |
0.0.0-<sha> |
Epoch-based | Pre-release |
Tag v1.2.3 |
1.2.3 |
Epoch-based | Full release |
Build iOS
Scaffolded but requires manual setup:
- Create Xcode project (see Native Host Apps)
- Enroll in Apple Developer Program ($99/year)
- Configure signing secrets
Currently builds an unsigned archive. 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.
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 | Falls to ground, no animation | 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
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 |
|---|---|---|
| <= -7.25 (rising fast) | -15 deg | Nose up |
| Between -7.25 and -5.25 | 0 deg | Level |
| >= -5.25 (falling) | 70 deg | Nose dive |
During nose dive, the animation frame is locked to frame 1 (wings level).
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: locked to frame 2 (wings up) 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).
Getting Started
Prerequisites
- Node.js 18+ and npm
- Java 17 and Android SDK (for Android builds)
- Xcode 15+ and CocoaPods (for iOS builds)
Quick Start
git clone https://github.com/jonathanperis/speedy-bird-lynx.git
cd speedy-bird-lynx
npm install
npm 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
npm run build
This outputs:
dist/main.lynx.bundle— native bundle for Android/iOSdist/main.web.bundle— web bundle
Android
npm 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). See Native Host Apps for setup instructions.
npm 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 (Standalone)
Open docs/index.html directly in any browser. This is a self-contained canvas-based version that requires no build step — the same version deployed to GitHub Pages.
Project Commands
| Command | Description |
|---|---|
npm run dev |
Start dev server with HMR |
npm run build |
Production build (Lynx + Web bundles) |
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 host apps for Android (complete) and iOS (source files).
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 |
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
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, set them in your shell or use a local.properties file.
iOS
The iOS host app source files are in ios/. An Xcode project must be created manually.
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
- Open Xcode > File > New > Project > App
- Product Name:
SpeedyBird - Bundle Identifier:
com.jonathanperis.speedybird - Language: Swift
- Product Name:
- Delete the auto-generated Swift files
- Add all files from
ios/SpeedyBird/to the project - Build Settings > Swift Compiler > Objective-C Bridging Header > set to
SpeedyBird/SpeedyBird-Bridging-Header.h - Build Settings > User Script Sandboxing > set to NO
- Terminal:
cd ios && pod install - Open
SpeedyBird.xcworkspace(not.xcodeproj) - 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 |