Speedy Bird Lynx

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

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/                         # 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 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.

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:

  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

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:

  1. npm ci — install dependencies
  2. npx tsc --noEmit — TypeScript type-checking
  3. npm run build — build Lynx and web bundles
  4. 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:

  1. Version computation — from git tag (v1.2.31.2.3) or short SHA (0.0.0-a1b2c3d)
  2. Build Lynx bundlenpm run build
  3. Copy bundle — into android/app/src/main/assets/
  4. Decode keystore — from KEYSTORE_BASE64 secret
  5. Gradle build./gradlew assembleRelease with signing env vars
  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

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:

  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. 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:

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

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

  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