A community-maintained, battle-tested guide for building apps on the Even Realities Even Hub platform for the G2 smart glasses.
This repository exists because building for the Even Hub SDK v0.0.9 involves a surprising number of undocumented quirks, silent failures, and hardware-vs-simulator discrepancies. Everything here was learned the hard way while shipping 8 production apps (listed below) and reading every piece of community code we could find.
If you're new to G2 development and wondering "why doesn't my tap event fire?" or "why does my image container render blank?" β this guide is for you.
- Why this guide exists
- Quick start
- Core concepts
- SDK quirks and workarounds
- Recipes
- Example projects
- Community & credits
- Contributing
The official Even Hub docs are sparse. The SDK has several known bugs that are not documented anywhere. The simulator behaves differently from real hardware in ways that silently break apps in production. Community knowledge is scattered across a handful of GitHub repos.
This guide consolidates that tribal knowledge into one place, with copy-pasteable code and clear explanations of why each workaround is needed.
Goal: get a new developer from zero to shipping a working G2 app in hours, not weeks.
npm create vite@latest my-g2-app -- --template vanilla-ts
cd my-g2-app
npm install @evenrealities/even_hub_sdk @evenrealities/evenhub-cli @evenrealities/evenhub-simulatorMinimum viable app (src/main.ts):
import { waitForEvenAppBridge, TextContainerProperty, CreateStartUpPageContainer } from '@evenrealities/even_hub_sdk'
async function init() {
const bridge = await waitForEvenAppBridge()
const text = new TextContainerProperty({
xPosition: 4, yPosition: 4,
width: 568, height: 280,
borderWidth: 0, borderColor: 0, borderRadius: 0, paddingLength: 8,
containerID: 0, containerName: 'main',
isEventCapture: 1,
content: 'Hello, G2!',
})
bridge.createStartUpPageContainer(new CreateStartUpPageContainer({
containerTotalNum: 1,
textObject: [text],
}))
bridge.onEvenHubEvent((event) => {
console.log('tap!', event)
})
}
init()Dev workflow:
npm run dev # Vite on :5173
npx evenhub qr --ip 192.168.1.100 --port 5173 # QR for sideloadScan QR with the Even Realities iOS/Android app β app loads on glasses with hot reload.
Read the full getting started guide.
[Your web app] --HTTPS--> [Phone WebView in Even App] --BLE--> [G2 glasses]
- Your app is a standard web app (HTML/CSS/TypeScript). No special framework.
- Runs in a WebView inside the native Even Realities phone app.
- The glasses are a display + input peripheral only β no code runs there.
- The SDK injects a JavaScript bridge (
EvenAppBridge) into the WebView.
- 576Γ288 pixels, 4-bit greyscale (16 shades of green on the physical micro-LED).
- Coordinate system: top-left origin, X increases right, Y increases down.
- No color, no font selection, no font sizing, no audio output, no camera.
You draw by creating containers (text, list, or image) via createStartUpPageContainer (called once) then rebuildPageContainer (for updates) or textContainerUpgrade (for flicker-free text updates).
- Max 4 containers per page in practice (docs say 12 β don't push it).
- Exactly one container must have
isEventCapture: 1or no taps will fire. - Images must be 4-bit indexed PNG, max 200Γ100 pixels (see pixel art recipe).
Tap, double-tap, scroll up/down, and lifecycle events all arrive via bridge.onEvenHubEvent(callback). This is where most bugs hide β see SDK quirks.
These are the non-obvious problems we discovered while shipping. Every one of them will bite you.
CLICK_EVENT has value 0. JSON parsing on the way to your callback drops the 0, so event.textEvent.eventType arrives as undefined.
Fix: normalize undefined/null back to 0:
function normalizeEventType(raw: unknown): number {
if (raw === undefined || raw === null) return 0 // CLICK_EVENT = 0
if (typeof raw === 'number') return raw
if (typeof raw === 'string') return parseInt(raw, 10) || 0
return -1
}This is the most insidious bug. Your app works perfectly in the simulator but all taps are silently dropped on real glasses.
On real G2 hardware, tap events arrive in the sysEvent field of the event object. The simulator routes them through textEvent / listEvent. Naive parsers only check textEvent β nothing ever fires.
Fix: always check sysEvent as a fallback:
function parseEvent(event: unknown): ParsedEvent | null {
const e = event as Record<string, unknown>
// Try listEvent first (has selectedIndex)
const listEvt = e.listEvent as Record<string, unknown> | undefined
if (listEvt && typeof listEvt === 'object') {
return { /* ... */ }
}
// Try textEvent (simulator)
const textEvt = e.textEvent as Record<string, unknown> | undefined
if (textEvt && typeof textEvt === 'object') {
return { /* ... */ }
}
// CRITICAL: Try sysEvent (real hardware sends here!)
const sysEvt = e.sysEvent as Record<string, unknown> | undefined
if (sysEvt && typeof sysEvt === 'object') {
const evtType = normalizeEventType(sysEvt.eventType)
// eventType 0-3 are user interactions; 4-8 are lifecycle/IMU
if (evtType >= 0 && evtType <= 3) {
return { action: eventTypeToAction(evtType), /* ... */ }
}
}
// Fallback: jsonData
const json = e.jsonData as Record<string, unknown> | undefined
if (json && typeof json === 'object') {
return { /* ... */ }
}
return null
}See docs/event-handling.md for the full template.
canvas.toDataURL('image/png') produces 24-bit RGBA PNGs. The G2 host's imageToGray4 function silently rejects them β no error, nothing displays.
Fix: use upng-js to produce 16-color indexed PNGs, with manual greyscale quantization to 16 levels (0, 17, 34, ..., 255).
import UPNG from 'upng-js'
function generatePNG(width: number, height: number, drawFn: (ctx) => void): number[] {
// 1. Draw with greyscale only, quantized to 16 levels
const rgba = new Uint8Array(width * height * 4)
// ... fill rgba with values like 0, 17, 34, ..., 255 ...
// 2. Encode as 16-color indexed PNG
const png = UPNG.encode([rgba.buffer], width, height, 16)
return Array.from(new Uint8Array(png))
}Credit: this approach is from fabioglimb/even-toolkit.
Docs say ImageContainerProperty supports up to 288Γ144 px. In practice on real hardware, anything over ~200Γ100 px is unreliable. Stay under that.
When the user taps the first item in a list container, the event sometimes arrives without currentSelectItemIndex. If you don't default to 0, the user can't select the top item.
Fix: explicit fallback:
selectedIndex: Number(listEvt.currentSelectItemIndex ?? 0)The hardware fires scroll events very rapidly. A single finger swipe can produce 5-10 events. Throttle them:
let lastScrollTime = 0
const SCROLL_COOLDOWN_MS = 300
if (action === 'scrollUp' || action === 'scrollDown') {
const now = Date.now()
if (now - lastScrollTime < SCROLL_COOLDOWN_MS) return
lastScrollTime = now
// handle scroll
}Calling updateImageRawData() for multiple containers concurrently locks up the bridge. Queue them:
let imageBusy = false
async function safeUpdateImage(...) {
if (imageBusy) return
imageBusy = true
try { await bridge.updateImageRawData(...) }
finally { imageBusy = false }
}You cannot send image data to a container that wasn't declared in the most recent createStartUpPageContainer or rebuildPageContainer. Sequence:
// 1. First, ensure a page exists (text-only on first send)
bridge.createStartUpPageContainer(new CreateStartUpPageContainer({ ... }))
// 2. Now rebuild with image container declared (empty placeholder)
bridge.rebuildPageContainer(new RebuildPageContainer({
containerTotalNum: 2,
textObject: [text],
imageObject: [new ImageContainerProperty({ containerID: 1, containerName: 'img', ... })],
}))
// 3. SHORT DELAY (100ms) before pushing image data
await new Promise(r => setTimeout(r, 100))
// 4. Now push the PNG bytes
await bridge.updateImageRawData(new ImageRawDataUpdate({
containerID: 1, containerName: 'img', imageData: pngBytes,
}))Skipping step 1 or 3 causes silent failures.
When the user navigates away from your app on the glasses, JavaScript timers keep firing but any rebuildPageContainer calls are silently dropped. This wastes battery. Handle FOREGROUND_EXIT_EVENT (4) and pause your timers; resume on FOREGROUND_ENTER_EVENT (5).
The SDK v0.0.9 has no notification, push, scheduled, or wakeup API for third-party apps. Your app must be in the foreground to deliver alerts. If you need reminder-style notifications without keeping the app open, build a companion mobile app with expo-notifications β the notifications forward to the G2 automatically via the native Even app (when the user enables "Notification Access").
Full list + explanations: docs/sdk-quirks.md.
docs/event-handling.md β drop-in parseEvent + handler skeleton with sysEvent/normalizer/lifecycle wired in.
docs/pixel-art.md β full sprite β PNG β updateImageRawData flow with upng-js, ASCII sprite definitions, greyscale quantization.
docs/lifecycle.md β foreground/background, device info, battery awareness, graceful shutdown, launch source differentiation.
docs/distributed-backend.md β when and how to offload work to a backend server (used by Speech Coach for real Whisper STT).
docs/mobile-companion.md β Expo app + expo-notifications that forwards to glasses via Even's native notification bridge. Reminder-style apps should use this pattern.
docs/testing.md β state/logic test suite pattern using tsx (no Jest setup needed).
docs/dev-workflow.md β hot reload with evenhub qr, simulator vs hardware gotchas, debug patterns.
docs/telemetry.md β Cloudflare Worker + client for remote error reporting.
Eight production apps built following the patterns in this guide. Each is open source on GitHub.
| Project | What it does | Techniques shown |
|---|---|---|
| eyefit-g2 | Eye exercise guidance | Pixel art character, IMU head tracking, lifecycle events |
| hunter-g2 | Place discovery with walking routes | Category pixel art icons, offline cache, OSRM routing, favorites |
| speechcoach-g2 | Live speech pace coaching | Audio capture, backend STT via SSE, pixel art mascot, VU meter |
| breakmate-g2 | Health reminders | Animated pixel art character, multi-frame walking animation |
| breakmate-mobile | Expo companion for BreakMate | expo-notifications, calendar integration, smart scheduling, quiet hours |
| eyefit-mobile | Expo companion for EyeFit | expo-notifications, scheduled reminders |
| speechcoach-backend | Node backend for STT | Express + SSE, Whisper/Deepgram integration, session store |
| g2-telemetry-worker | Error reporting endpoint | Cloudflare Worker, KV storage, CORS |
This guide stands on the shoulders of the open-source G2 developer community. Huge thanks to:
- BxNxM β even-dev is THE reference simulator + 16 example apps. The fastest way for newcomers to get started.
- nickustinov β even-g2-notes is the community documentation bible. paddle-even-g2 shows clean minimal game architecture.
- fabioglimb β even-toolkit pioneered the working pixel art recipe with upng-js. This entire guide's pixel art section is built on their work.
- kqb β openclaw-g2-hud is a production-quality reference for audio capture + WebSocket backend + notifications.
- sam-siavoshian β claude-code-g2 demonstrates distributed architecture (backend + phone WebView + glasses).
- 200even β flappy-g2 minimal game reference.
- i-soxi β even-g2-protocol reverse engineering of the BLE protocol, for when you need to go below the SDK.
- Even Realities β EvenDemoApp and EH-InNovel as official references.
If your project should be listed here, open a PR or issue.
PRs welcome! See CONTRIBUTING.md for details.
Particularly needed:
- Counter-examples: "Bug #X does NOT happen in SDK v0.0.Y" β we want to know when bugs get fixed
- More recipes: anything you struggled with and figured out
- Translations: this guide is English; translations to other languages are welcome
- Corrections: if anything here is wrong or outdated, please fix it
MIT. Use freely, share freely.
This guide is not affiliated with Even Realities. It's a community project documenting what actually works based on shipping real apps. The SDK may change; quirks may be fixed in future versions. We try to keep things up to date but can't guarantee it.