AI-first Canvas 2D game framework. Every element is a UINode — inspectable, recordable, replayable.
# 1. Configure GitHub Packages registry (one-time)
echo "@lucid-2d:registry=https://npm.pkg.github.com" >> .npmrc
# 2. Install
npm install @lucid-2d/core @lucid-2d/engine @lucid-2d/ui
# optional
npm install @lucid-2d/physics @lucid-2d/game-ui @lucid-2d/systems
# dev only (headless rendering)
npm install -D @napi-rs/canvasimport { createApp } from '@lucid-2d/engine';
import { SceneNode } from '@lucid-2d/engine';
import { Button, Label, UIColors } from '@lucid-2d/ui';
class MenuScene extends SceneNode {
constructor(private app) { super({ id: 'menu' }); }
onEnter() {
const btn = new Button({ id: 'start', text: 'Play', variant: 'primary', width: 200, height: 50 });
btn.x = 95; btn.y = 400;
btn.$on('tap', () => console.log('tapped'));
this.addChild(btn);
}
protected draw(ctx) {
ctx.fillStyle = UIColors.bgTop;
ctx.fillRect(0, 0, 390, 844);
}
}
const app = createApp({ platform: 'web', canvas: document.getElementById('game'), debug: true });
app.router.push(new MenuScene(app));
app.start();
window._app = app; // expose for AI agentNode tree, events, animation, recording, timers, RNG, sprites, text, camera, nine-slice, i18n, entity.
import { UINode, Entity, Sprite, SpriteSheet, AnimatedSprite, NineSlice, Camera, I18n, InteractionRecorder, SeededRNG, Timer, CountdownTimer, wrapText, drawText, drawRoundRect } from '@lucid-2d/core';| Export | Type | Description |
|---|---|---|
UINode |
class | Base node. Constructor accepts { id, x, y, width, height, visible, interactive, alpha, ...layout }. Methods: addChild(node, index?), removeChild(node), removeFromParent(), findById(id), hitTest(x, y), $inspect(depth?), $on(event, handler), $emit(event, ...args), $animate(props, duration, easing?), $patch(props), $query(selector), $snapshot(). Static: UINode.$diff(before, after). Override: draw(ctx), $update(dt), $fixedUpdate(dt), $render(ctx), $inspectInfo(), $type. |
Entity |
class | AI-visible proxy for non-UINode game objects. Entity.from(obj, { id, type?, props? }). Zero rendering cost. Proxies $inspect/$query/$patch/$snapshot/$restore to the source object. $restore(snapshot) restores props for AI bot forward search (Expectimax/MCTS). source setter for pooling. |
InteractionRecorder |
class | Records touch events with node paths and timestamps. start(), stop(), dump(), clear(). |
SeededRNG |
class | Mulberry32 deterministic RNG. next(): number, int(min, max), pick(array), shuffle(array), fork(): SeededRNG. |
Timer |
class | Elapsed time tracker. elapsed, pause(), resume(), reset(). |
CountdownTimer |
class | Countdown with callbacks. remaining, progress, onTick, onComplete. |
Sprite |
class | Image UINode. Props: image (any CanvasImageSource), sourceRect?: {x,y,w,h} (sprite sheet cropping), flipX?, flipY?. Draws via ctx.drawImage. |
SpriteSheet |
class | Named regions in a sprite atlas. new SpriteSheet(image, { idle: {x,y,w,h}, ... }). Methods: getRegion(name), createSprite(name, opts?), createAnimated(names, opts?), regionNames. Static: fromGrid(image, cols, rows, cellW, cellH, names?). |
AnimatedSprite |
class | Frame sequence animation. Props: frames: FrameDef[], fps?: number, mode?: 'loop'|'once'|'pingpong', autoPlay?, flipX?, flipY?. Methods: play(), pause(), stop(), restart(). Events: complete (once mode). Static: fromImages(images, opts?). |
wrapText(ctx, text, maxWidth) |
function | Auto line-wrap (word boundaries for Latin, character boundaries for CJK). Returns string[]. |
drawText(ctx, text, opts) |
function | Multi-line text rendering with alignment, vertical alignment, and maxLines ellipsis truncation. |
measureWrappedText(ctx, text, maxWidth, lineHeight) |
function | Measure wrapped text dimensions. Returns { width, height, lines }. |
Camera |
class | Viewport for scrolling/zooming worlds. Props: viewWidth, viewHeight, worldWidth?, worldHeight?. Methods: moveTo(x,y), moveBy(dx,dy), follow(target, opts?), update(dt), apply(ctx), restore(ctx), screenToWorld(sx,sy), worldToScreen(wx,wy), isVisible(x,y,w,h). |
NineSlice |
class | Nine-slice scaling UINode for panels/buttons. Props: image, insets: [top, right, bottom, left]. Corners stay fixed, edges stretch single-axis, center stretches both. |
I18n |
class | Internationalization. new I18n({ en: {...}, zh: {...} }). Methods: t(key, ...args) (positional params {0}, {1}), locale getter/setter, add(locale, translations), has(key), locales. Fallback: current → first locale → key. |
drawRoundRect(ctx, x, y, w, h, r) |
function | Cross-browser rounded rectangle path. Uses native ctx.roundRect() when available, falls back to quadraticCurveTo. Only creates path — call fill() or stroke() after. |
App lifecycle, scene routing, platform adapters, image loading, audio, keyboard.
import { createApp, SceneNode, SceneRouter, loadImage, WebAdapter, WxAdapter, TtAdapter } from '@lucid-2d/engine';| Export | Type | Description |
|---|---|---|
createApp(opts) |
function | Creates app. Options: { platform?, canvas?, adapter?, debug?, debugOverlay?, debugPanel?, rngSeed?, fixedTimestep?, assetRoot? }. Returns App with .root, .router, .screen, .rng, .debug, .debugOverlay, .timeScale, .fixedTimestep, .debugPanel, .renderOneFrame(), .simulateTouch(x,y), .applyPreset(name), .listPresets(), .settle(frames?, intervalMs?). assetRoot auto-configures loadImage() path resolution for cross-platform builds. |
SceneNode |
class | Extends UINode. Override onEnter(), onExit(), onPause(), onResume(), preload() (async resource loading before onEnter), $fixedUpdate(dt), $presets() (declare screenshot-able states). |
boot(opts) |
function | Universal entry point. Auto-detects platform, finds/creates canvas, starts app. Options: { canvas?, canvasId?, autoCanvas?, onReady?, ...AppOptions }. Returns Promise<App>. |
SceneRouter |
class | push(scene, transition?), replace(scene, transition?), pop(transition?). Transition: { type: 'fade'|'slideLeft'|'slideRight'|'slideUp'|'slideDown'|'custom', duration, render? }. custom type: render(ctx, progress, oldScene, newScene) takes over all rendering. Set defaultTransition for global default. |
LucidTouchEvent |
interface | Touch event data: { x, y, localX, localY, worldX, worldY }. x/y are world coordinates (aliases for worldX/worldY). |
WebAdapter |
class | Browser platform. Auto-creates from canvas element. Does NOT set inline CSS — developer controls display sizing. |
WxAdapter |
class | WeChat Mini Game platform. Uses wx.* globals. |
TtAdapter |
class | Douyin Mini Game platform. Uses tt.* globals. |
loadImage(src, timeout?) |
function | Platform-aware async image loader. Returns Promise<ImageLike>. Auto-detects Web/Wx/Tt. Relative paths auto-resolved via assetRoot. |
setAssetRoot(root) |
function | Set root path for loadImage() relative path resolution. Called automatically by createApp({ assetRoot }). |
createTestApp(opts?) |
function | Headless test app — use this instead of Playwright. Options: { render?, width?, height?, assetRoot?, fonts? }. When render: true, uses @napi-rs/canvas for real PNG output with auto CJK font registration. Returns TestApp with toImage(): Buffer, saveImage(path). 50ms startup, 100% deterministic, native Canvas touch simulation. |
tap(app, nodeId) |
function | Simulate tap on node by id. Emits touchstart + touchend. Returns true if found. |
touch(app, x, y, type?) |
function | Simulate touch at coordinates via hitTest. Type: 'start' | 'end' | 'move', default: full tap. Returns node path. |
assertTree(app, pattern) |
function | Assert $inspect() output contains all pattern lines (trimmed, ignoring extra nodes). Throws with diff on failure. |
auditUX(app, opts?) |
function | UX audit engine. Runs 26 built-in rules (touch targets, safe area, navigation, overlap, emoji, components, etc.). Returns { pass, issues, summary, layout }. Options: { ignore?, rules?, strict? }. When strict: true, warnings also cause pass=false. |
auditAll(app, entries, opts?) |
function | Batch audit multiple scenes. Takes { scene, label, setup? }[], returns { allPassed, sceneCount, perScene, summary }. Passes opts (including strict) through to auditUX. |
defineRule(rule) |
function | Register custom audit rule. { id, severity, test(app, screen) => AuditIssue[] }. |
generateTestCode(records) |
function | Convert InteractionRecord[] (from dumpInteractions()) to vitest test code string. |
AudioManager |
class | Cross-platform audio. load(name, src), register(name, handle) (custom AudioHandle, e.g. Web Audio API synth), playSfx(name), playBgm(name, opts?), stopBgm(), pauseBgm(), resumeBgm(). Props: sfxVolume, bgmVolume, muted. |
createAudio(opts?) |
function | Create AudioManager with auto-detected platform (Web/Wx/Tt). |
createMockAudio() |
function | Mock AudioManager for testing (no real playback). |
Keyboard |
class | PC keyboard input. bind(target), isDown(key), wasPressed(key), wasReleased(key), update(). Test: simulatePress(key), simulateRelease(key). |
AssetLoader |
class | Batch asset loading. add(name, src), load(): Promise<Map>, onProgress, progress, get(name). Auto-detects image/audio/json. |
timeSlice(opts) |
function | Split heavy computation across frames. { total, batch?, work(i), onProgress? }. Yields via rAF between batches to keep UI responsive. |
createOffscreenCanvas(w, h) |
function | Cross-platform offscreen canvas (Web: OffscreenCanvas/createElement, WX: wx.createOffscreenCanvas, TT: tt.createOffscreenCanvas, Headless: via registered factory). |
11 base components + design tokens.
import { Button, Label, Icon, Modal, Toggle, TabBar, ScrollView, ProgressBar, Toast, Badge, Tag, UIColors } from '@lucid-2d/ui';| Component | Key Props | Events |
|---|---|---|
Button |
id, text, variant: 'primary'|'outline'|'ghost'|'gold', width, height, disabled, fontSize? |
tap. AI: $inspectInfo = variant, $highlighted = pressed, $disabled. |
Label |
text, fontSize, fontWeight, color, align, wrap?, maxLines?, lineHeight?, verticalAlign? |
— . AI: lineCount, truncated, renderedLines. $inspectInfo: 3lines truncated. |
Icon |
name: IconName, size, color |
— . AI: $text = icon name. |
Modal |
title, id, width, height, screenWidth, screenHeight |
close. Methods: open(), close(), fitContent(bottomPad?). AI: $inspectInfo = open/closing/closed. |
Toggle |
id, label, value: boolean, width, height |
change(value). AI: $highlighted = ON state. |
TabBar |
id, tabs: TabItem[], activeKey, width, height |
change(key). AI: $inspectInfo = active key. |
ScrollView |
id, width, height, contentHeight |
Touch-drag scrolling. Auto-captures touch (scroll vs tap detection). scrollY, maxScrollY. $inspectInfo: scroll position. |
ProgressBar |
id, width, height, value: 0..1, color?, colorStops?: ColorStop[], label?, labelColor? |
— . colorStops: value-based color transitions (e.g. green→yellow→red). label: overlay text (e.g. "23/50"). AI: $text = percentage + label. |
Toast |
Singleton object | Toast.show(type, message). Needs Toast.update(dt) + Toast.draw(ctx, w, h) per frame. |
Badge |
text, color |
— |
Tag |
text, color |
— |
Icon names (29): check, close, coin, diamond, heart, star, lock, unlock, gift, fire, lightning, shield, sword, crown, trophy, medal, arrow-up, arrow-down, arrow-left, arrow-right, play, pause, settings, home, share, volume-on, volume-off, refresh, info.
UIColors (28 tokens): primary, secondary, accent, text, textSecondary, textLight, textMuted, textHint, panelFill, panelBorder, bgTop, bgBottom, cardBg, trackBg, divider, success, error, warning, goldStart, goldEnd, dangerStart, dangerEnd, overlayBg, buttonText, buttonOutlineText, buttonGhostText, toggleOn, toggleOff.
Template system (v0.5–v0.6) + 12 business components. Games use createScene() to declare scenes — framework handles all layout, components, and validation. MenuTemplate uses Zone A/C/D layout model for full-featured game menus. ResultTemplate supports countdown, double reward, and rank change (v0.6).
import { createScene, type MenuConfig, type GameplayConfig } from '@lucid-2d/game-ui';| Export | Type | Description |
|---|---|---|
createScene(app, config) |
function | The single entry point for scene creation. Takes a template config, validates required actions, returns a TemplateScene. Templates: menu, gameplay, result, map, shop, list, pass. |
TemplateScene |
class | SceneNode with __template marker. Created by createScene(), not by games directly. |
isTemplateScene(node) |
function | Check if a node is a template scene. |
ACTION_DEFAULTS |
const | Default icon/text/variant/size for each ActionCode. |
7 scene templates:
| Template | Required | Optional | Custom Areas |
|---|---|---|---|
menu |
play, settings, privacy | zoneA, zoneC, zoneD, toggles, cornerLeft/Right, bestScore, stats, continueGame, checkin, lucky-box, help, version | drawBackground, heroContent |
gameplay |
pause (→ PauseModal with resume+restart+home) , setup | hud | drawBackground |
result |
restart or home | share, ad, revive, stats, countdown, doubleReward, rankChange, isNewBest | drawBackground |
map |
back, setup | title | drawBackground |
shop |
back | tabs, items (skin) / coinItems (coin) | drawBackground |
list |
back | tabs, entries | drawBackground |
pass |
back | rewards, onClaim, onBuyPremium | drawBackground |
12 business components (used internally by templates, also available for direct use):
| Component | Purpose |
|---|---|
CheckinDialog |
Daily check-in modal |
ShopPanel |
Shop with tabs + item grid |
SettingsPanel |
Settings toggles + links |
ResultPanel |
Score + stats display |
LeaderboardPanel |
Ranked player list |
BattlePassPanel |
Season pass rewards |
LuckyBoxDialog |
Random reward modal |
CoinShopPanel |
IAP coin packages |
PrivacyDialog |
Privacy policy modal |
ConfirmDialog |
Confirm/cancel action modal |
PauseModal |
Pause menu with resume/restart/home/settings |
TutorialOverlay |
Step-by-step tutorial with target highlighting |
10 operation systems. All use Storage interface for persistence.
import { createStorage, createPreferences, CheckinSystem, SkinSystem, AchievementSystem, MissionSystem, BattlePassSystem, AdSystem, IAPSystem, ShareSystem, AnalyticsSystem } from '@lucid-2d/systems';| System | Constructor | Key Methods |
|---|---|---|
createStorage(prefix?) |
Auto-detects platform | Returns Storage (Web/Wx/Tt/Memory). |
CheckinSystem |
{ storage, rewards: number[], prefix? } |
getState(): CheckinState, claim(): number (returns reward, 0 if already claimed). |
SkinSystem |
{ storage, skins: SkinDefinition[], prefix? } |
isOwned(id), getEquipped(category), purchase(id), equip(id), getSkinsByCategory(cat). Events: purchase, equip. |
AchievementSystem |
{ storage, achievements: AchievementDefinition[], prefix? } |
increment(id, amount?), getStatus(id), getAll(). Events: unlock. |
MissionSystem |
{ storage, missions: MissionDefinition[], prefix? } |
increment(id, amount?), claim(id), getStatus(id), resetDaily(). Events: complete, claim. |
BattlePassSystem |
{ storage, config: BattlePassConfig, prefix? } |
addXP(amount), claimReward(level, track), getState(). |
AdSystem |
{ adapter: AdAdapter } |
showRewarded(placement), showInterstitial(), showBanner(), preload(). Built-in adapters: NoopAdAdapter (dev), WxAdAdapter (WeChat), TtAdAdapter (Douyin). |
IAPSystem |
{ adapter: IAPAdapter, products } |
purchase(productId), getProducts(). Built-in adapters: NoopIAPAdapter (dev), WxIAPAdapter (WeChat Midas), TtIAPAdapter (Douyin). |
ShareSystem |
{ adapter: ShareAdapter } |
share(data) (active), enablePassiveShare(data) (menu forward), setDefaultShare(data) (dynamic update). Built-in adapters: WebShareAdapter, WxShareAdapter (onShareAppMessage + onShareTimeline + showShareMenu), TtShareAdapter. |
AnalyticsSystem |
{ adapters: AnalyticsAdapter[] } |
track(event, params?), setUser(id, props?). |
createPreferences(opts) |
{ storage, defaults, prefix? } |
get(key), set(key, value) (auto-persists), onChange(key, handler), reset(key), resetAll(), getAll(). |
Vectors, collision, particles, screen shake, bezier paths.
import { vec2, add, sub, scale, normalize, distance, pointInRect, circleCircle, ParticlePool, ParticleEmitter, ParticlePresets, ScreenShake, BezierPath } from '@lucid-2d/physics';| Export | Description |
|---|---|
vec2(x, y) |
Create Vec2. All vec2 ops are pure functions: add, sub, scale, normalize, dot, cross, distance, angle, fromAngle, perp, lerp, reflect. |
pointInRect, pointInCircle, circleRect, circleCircle, lineCircle |
Collision detection. Returns CollisionResult with normal and overlap. |
lineCircleDetailed(a, b, center, r) |
Line-circle intersection with hit point, distance, normal, t parameter. For projectile tracing and bot aiming. |
raycast(origin, dir, targets, maxDist?) |
Cast ray against circle targets, find nearest hit. targets: { x, y, radius, id? }[]. Returns RaycastHit | null with target, point, distance, normal. |
ParticlePool |
Object pool with built-in renderer. new ParticlePool(capacity, opts?) — opts.drawParticle?(ctx, p, t) for custom rendering. emit(x, y, opts?), update(dt), draw(ctx), clear(). pool.active returns active particles for manual iteration. Supports alpha fade, scale animation, friction. |
ParticleEmitter |
Continuous emitter. new ParticleEmitter(pool, config). Props: x, y, active. Methods: update(dt), start(), stop(). Config: rate (particles/sec) + all EmitOptions. |
ParticlePresets |
5 presets: explosion(), sparkle(), smoke(), fire(), trail(). Each returns EmitOptions/EmitterConfig, accepts overrides. |
ScreenShake |
start(intensity, duration), update(dt), apply(ctx) / restore(ctx). |
BezierPath |
Smooth curve with distance-based point lookup. addCubic(p0,p1,p2,p3), addQuadratic(p0,p1,p2), addLine(p0,p1), build(samples?). Query: getPointAtDistance(d), getPointAtT(t) → { x, y, angle, distance }. length, points, drawDebug(ctx). Fluent API. |
Flexbox subset. Set layout on any UINode to auto-position children.
| Property | Values | Description |
|---|---|---|
layout |
'row' | 'column' |
Enable auto-layout |
gap |
number |
Spacing between children |
padding |
number | [top, right, bottom, left] |
Inner padding |
alignItems |
'start' | 'center' | 'end' |
Cross-axis alignment |
justifyContent |
'start' | 'center' | 'end' | 'space-between' | 'space-around' | 'space-evenly' |
Main-axis distribution |
wrap |
boolean |
Enable row wrapping |
columns |
number |
Force N items per row (auto-calculates width) |
| Property | Values | Description |
|---|---|---|
flex |
number |
Fill remaining space (proportional if multiple) |
alignSelf |
'start' | 'center' | 'end' |
Override parent's alignItems |
minWidth / maxWidth |
number |
Constrain flex sizing |
minHeight / maxHeight |
number |
Constrain flex sizing |
// Vertical centered menu — no manual x/y
const menu = new UINode({ width: 390, height: 844, layout: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 });
menu.addChild(new Button({ text: 'Play', width: 200, height: 50 }));
menu.addChild(new Button({ text: 'Settings', width: 200, height: 44 }));
// HUD: left / center / right
const hud = new UINode({ width: 390, height: 44, layout: 'row', justifyContent: 'space-between', alignItems: 'center', padding: [0, 16, 0, 16] });
// Flex fill
const body = new UINode({ flex: 1, minHeight: 100 });
// Shop grid: 4 columns
const grid = new UINode({ width: 360, layout: 'row', wrap: true, columns: 4, gap: 8 });Ready-to-run game templates in templates/ directory.
| Template | Description | Run |
|---|---|---|
starter |
Minimal game: menu → tap-the-target → result | npx vite --config templates/starter/vite.config.ts |
quiz |
Quiz game: question bank → timed options → scoring | npx vite --config templates/quiz/vite.config.ts |
Each template includes:
index.html+vite.config.ts+tsconfig.json- 3 scenes: menu → game/quiz → result
- Layout system usage (no manual x/y)
$inspect()with game state- Example test file with
createTestApp+assertTree
const app = createApp({ debug: true, debugPanel: true });
// Floating "D" button appears in bottom-right corner
// Tap → panel shows: scene tree, FPS, router depth, timeScale
// "Copy" → full state dump to clipboard, paste to AI for analysis
// Programmatic access:
const dump = app.debugPanel.dump(); // structured object
const text = app.debugPanel.dumpText(); // AI-friendly text formatThe debug panel eliminates the need to build custom debug UI in every game. One line of config gives AI full visibility into runtime state.
import { createTestApp, tap, touch, assertTree } from '@lucid-2d/engine/testing';
const app = createTestApp({ render: true }); // real canvas via @napi-rs/canvas
await app.router.push(new MenuScene(app)); // await if scene has preload()
app.tick(16);
app.saveImage('menu.png'); // save screenshot
const buf = app.toImage(); // get PNG buffer
// Debug overlay: shows node boundaries + IDs
app.debugOverlay = true;
app.tick(16);
app.saveImage('menu-debug.png');// Query nodes (CSS-like selectors)
const buttons = app.root.$query('Button');
const play = app.root.$query('#play');
const menuBtns = app.root.$query('MenuScene Button');
const interactive = app.root.$query('.interactive');
// Batch-modify properties at runtime
app.root.findById('btn').$patch({ x: 100, width: 200 });
// Snapshot + diff for change detection
const before = app.root.$snapshot();
app.root.findById('score').$patch({ x: 50 });
const after = app.root.$snapshot();
const changes = UINode.$diff(before, after);
// → [{ path: 'root > score', prop: 'x', from: 0, to: 50 }]
// Custom state in $inspect output
class GameScene extends SceneNode {
protected $inspectInfo() { return `score=${this.score} level=${this.level}`; }
}
// Pixel-level image comparison
const before = app.toImage();
tap(app, 'play');
app.tick(16);
const after = app.toImage();
const diff = await imageDiff(before, after);
// { totalPixels, diffPixels, diffPercent, identical, sameDimensions }
await assertImageChanged(before, after, 0.01, 0.5); // 1%-50% changed
// Verify text wrapping
const label = app.root.findById('desc') as Label;
expect(label.lineCount).toBe(3);
expect(label.truncated).toBe(false);// navigate to game
await page.goto('http://localhost:3456');
// inspect tree
const tree = await page.evaluate(() => window._app.root.$inspect());
// find and click
await page.evaluate(() => {
const btn = window._app.root.findById('start');
btn.$emit('tap');
});
// record and replay
const records = await page.evaluate(() => window._app.dumpInteractions());
await page.evaluate((r) => window._app.replay(r, 2), records);_app.root — root UINode
_app.root.$inspect() — text tree snapshot
_app.root.findById(id) — find node by id
_app.root.$query(selector) — CSS-like query (.interactive, Button, #id)
_app.root.$snapshot() — structured state snapshot
_app.router — scene router (.push / .replace / .pop)
_app.rng — SeededRNG instance
_app.debugOverlay — toggle debug overlay (node borders/IDs)
_app.timeScale — time scale (0=pause, 0.5=slow, 1=normal, 2=fast)
_app.dumpInteractions() — get recorded touch events
_app.replay(records, speed) — replay (async, returns ReplayStep[])
Traditional games rely on human designers to hand-place every collectible, obstacle, and enemy. Lucid enables a fundamentally different approach: AI generates, simulates, evaluates, and iterates on level designs autonomously. Humans only play and give feedback.
AI generates level variant
→ Lucid simulates gameplay headlessly (createTestApp + tick)
→ AI evaluates metrics ($inspect, $query, toImage)
→ AI adjusts design based on results
→ Repeat until quality threshold met
→ Human plays and gives feedback
→ AI incorporates feedback and regenerates
import { batchSimulate, createTestApp } from '@lucid-2d/engine';
const result = batchSimulate({
count: 50,
setup: (i) => {
const app = createTestApp({ render: true });
const level = generateLevel(baseSeed + i); // your level generator
app.router.push(new PlayScene(app, level));
return app;
},
run: (app) => {
// Fast-forward 30 seconds of gameplay
for (let t = 0; t < 1800; t++) app.tick(16);
},
evaluate: (app) => ({
score: parseInt(app.root.findById('score')?.$text ?? '0'),
collected: app.root.$query('.collected').length,
missed: app.root.$query('.missed').length,
}),
screenshot: (i, m) => m.score > 1000, // save screenshots of good runs
});
// Analyze results
console.log(result.summary);
// { score: { min: 200, max: 1500, avg: 800 }, collected: { ... } }
// Find best variant
const best = result.results.sort((a, b) => b.metrics.score - a.metrics.score)[0];
best.image && fs.writeFileSync('best-level.png', best.image);When AI evaluates a level, it needs to see game objects, not just UI elements. Entity bridges this gap:
import { Entity } from '@lucid-2d/core';
class GameScene extends SceneNode {
onEnter() {
// Register game objects for AI visibility
for (const planet of this.planets) {
this.addChild(Entity.from(planet, {
id: `planet-${planet.id}`,
props: ['x', 'y', 'radius', 'mass'],
}));
}
for (const dust of this.dusts) {
this.addChild(Entity.from(dust, {
id: `dust-${dust.id}`,
type: dust.isMemory ? 'MemoryDust' : 'Dust',
props: ['x', 'y', 'alive'],
}));
}
}
}
// Now AI can evaluate in batchSimulate:
evaluate: (app) => ({
dustsCollected: app.root.$query('Dust').filter(e => !e.source.alive).length,
totalDusts: app.root.$query('Dust').length,
shipAlive: app.root.findById('ship')?.source.state !== 'dead',
memoryFound: app.root.$query('MemoryDust').some(e => !e.source.alive),
}),Without Entity, AI can only see "GameScene#game score=3/4" — it can't tell which dust was missed or why. With Entity, AI sees every game object and can make informed design decisions.
Entity + $fixedUpdate + batchSimulate form the complete AI evaluation pipeline:
$fixedUpdate→ deterministic physics (sim matches game)Entity→ full game state visibility (AI sees everything)batchSimulate→ parallel variant comparison (find the best)
| Capability | How AI uses it |
|---|---|
createTestApp |
Run game without browser |
tick(16) |
Fast-forward gameplay (1800 ticks = 30s in ~100ms) |
$fixedUpdate |
Deterministic physics (sim = game) |
Entity |
See/query/modify game objects during simulation |
$inspect / $query |
Read full game state programmatically |
$patch |
Adjust level parameters at runtime |
batchSimulate |
Compare 50+ variants automatically |
SeededRNG |
Reproducible randomness for each variant |
For validation that doesn't need rendering — physics reachability, collision testing, path planning, economy balance — import game modules directly instead of going through createTestApp/batchSimulate:
// level-validator.ts — pure Node.js, no canvas, no framework overhead
import { updatePhysics, createShip, launch } from './physics.js';
import { ALL_LEVELS } from './levels.js';
for (const level of ALL_LEVELS) {
for (let angle = 0; angle < 360; angle += 10) {
const ship = createShip();
launch(ship, angle);
for (let step = 0; step < 600; step++) {
updatePhysics(ship, level.planets, 0.016);
// check metrics...
}
}
}
// 50 levels × 36 angles × 600 steps = 1M ticks in < 2 secondsThis works because @lucid-2d/physics (Vec2, collision) and your game logic modules are independently importable — no canvas, no Scene, no App required. Design your game modules this way: keep physics/logic decoupled from rendering so they can run in both browser and Node.js.
Use batchSimulate when you need the full stack — Scene lifecycle, UI state, screenshots, $inspect trees. Use direct module imports when you only need logic.
- Level generator: How to create level layouts (procedural or template-based)
- AutoPlayer (optional): AI strategy for simulating gameplay
- Quality metrics: What makes a "good" level for your game type
The framework handles the infrastructure. The AI handles the creativity.
Lucid's test utilities (createTestApp, tap, touch, assertTree) can test interaction logic without a browser. Use them to catch event handling, hitTest, and penetration bugs automatically.
import { createTestApp, tap, touch, assertTree } from '@lucid-2d/engine/testing';
test('tap play button switches to game scene', () => {
const app = createTestApp();
app.router.push(new MenuScene(app));
app.tick(16);
tap(app, 'play-btn');
app.tick(16);
assertTree(app, `
GameScene#game
`);
});test('modal blocks game button', () => {
const app = createTestApp();
const scene = new SceneNode({ id: 'game', width: 390, height: 844 });
const btn = new UINode({ id: 'btn', x: 100, y: 400, width: 200, height: 50, interactive: true });
let tapped = false;
btn.$on('tap', () => { tapped = true; });
scene.addChild(btn);
app.router.push(scene);
app.tick(16);
// Button works before modal
touch(app, 200, 425);
expect(tapped).toBe(true);
// Open modal — button should be blocked
tapped = false;
const modal = new Modal({ title: 'Confirm' });
scene.addChild(modal);
modal.open();
app.tick(16);
touch(app, 200, 425);
expect(tapped).toBe(false); // modal blocks penetration
});test('small movement = tap, large movement = scroll', () => {
const sv = new ScrollView({ width: 300, height: 400 });
sv.contentHeight = 1000;
const btn = new UINode({ id: 'btn', x: 50, y: 50, width: 100, height: 40, interactive: true });
let tapped = false;
btn.$on('touchend', () => { tapped = true; });
sv.content.addChild(btn);
// Small move (3px < 5px threshold) → tap forwarded
sv.$emit('touchstart', { x: 100, y: 70, localX: 100, localY: 70, worldX: 100, worldY: 70 });
sv.$emit('touchend', { localX: 100, localY: 67, worldX: 100, worldY: 67 });
expect(tapped).toBe(true);
// Large move (30px > threshold) → scroll, no tap
tapped = false;
sv.$emit('touchstart', { x: 100, y: 70, localX: 100, localY: 70, worldX: 100, worldY: 70 });
sv.$emit('touchmove', { localX: 100, localY: 40 });
sv.$emit('touchend', { localX: 100, localY: 40, worldX: 100, worldY: 40 });
expect(tapped).toBe(false);
expect(sv.scrollY).toBe(30);
});test('AI can query and patch game objects', () => {
const app = createTestApp();
const scene = new SceneNode({ id: 'game', width: 390, height: 844 });
const ship = { x: 100, y: 200, speed: 5 };
scene.addChild(Entity.from(ship, { id: 'ship', props: ['x', 'y', 'speed'] }));
app.router.push(scene);
// AI query
const ships = app.root.$query('Ship');
expect(ships).toHaveLength(1);
// AI patch
app.root.findById('ship')!.$patch({ speed: 10 });
expect(ship.speed).toBe(10);
// AI inspect
expect(app.root.$inspect()).toContain('Ship#ship');
expect(app.root.$inspect()).toContain('speed=10');
});test('paused scene buttons do not receive tap', () => {
const app = createTestApp();
const menu = new SceneNode({ id: 'menu', width: 390, height: 844 });
const btn = new UINode({ id: 'play', x: 100, y: 400, width: 200, height: 50, interactive: true });
let tapped = false;
btn.$on('tap', () => { tapped = true; });
menu.addChild(btn);
app.router.push(menu);
app.tick(16);
touch(app, 200, 425);
expect(tapped).toBe(true); // works before push
tapped = false;
app.router.push(new SceneNode({ id: 'game', width: 390, height: 844 }));
app.tick(16);
touch(app, 200, 425);
expect(tapped).toBe(false); // blocked by new scene
});test('push/pop triggers correct lifecycle', () => {
const app = createTestApp();
const calls: string[] = [];
class TrackScene extends SceneNode {
onEnter() { calls.push(`${this.id}:enter`); }
onPause() { calls.push(`${this.id}:pause`); }
onResume() { calls.push(`${this.id}:resume`); }
onExit() { calls.push(`${this.id}:exit`); }
}
app.router.push(new TrackScene({ id: 'map' }));
app.router.push(new TrackScene({ id: 'battle' }));
app.router.pop();
expect(calls).toEqual([
'map:enter', 'map:pause',
'battle:enter',
'battle:exit', 'map:resume',
]);
});test('same seed + same ticks = same state', () => {
function runSim(seed: number) {
const app = createTestApp({ rngSeed: seed, fixedTimestep: 1/60 });
app.router.push(new GameScene()); // your game scene
for (let i = 0; i < 300; i++) app.tick(16);
return app.root.$inspect();
}
expect(runSim(42)).toBe(runSim(42)); // deterministic
expect(runSim(42)).not.toBe(runSim(99)); // different seed = different result
});For physics validation (reachability, balance, trajectories), import game modules directly — no canvas, no Scene, no framework overhead:
import { updatePhysics, createShip, launch } from './systems/physics';
test('ship escapes planet gravity at boost=1.6', () => {
const ship = createShip();
const planet = { x: 200, y: 200, radius: 30, mass: 1 };
placeOnOrbit(ship, planet);
launch(ship);
for (let i = 0; i < 600; i++) {
updatePhysics(ship, [planet], 0.016, 390, 844);
}
const escaped = ship.x < 0 || ship.x > 390 || ship.y < 0 || ship.y > 844;
expect(escaped).toBe(true);
});Use createMockAudio() for tests. If your game code uses raw Web Audio API, add a headless guard:
import { createMockAudio } from '@lucid-2d/engine/testing';
// In tests:
const audio = createMockAudio();
audio.register('hit', myCustomHandle);
audio.playSfx('hit'); // no real sound, no crash
// In game code with raw Web Audio:
const hasAudio = typeof window !== 'undefined' && typeof AudioContext !== 'undefined';
export function sfxPlay() {
if (!hasAudio) return; // silent in headless
}UI component level:
| Test type | What it catches | Example |
|---|---|---|
tap(app, id) |
Event handler not registered | DebugPanel v0.2.9 ($onMounted typo) |
touch(app, x, y) + hitTest |
Wrong node receives event | DebugPanel v0.2.12 (penetration) |
Modal + touch at bg node |
Click penetration through overlay | SceneRouter v0.2.6 (paused scene) |
$emit('touchmove') threshold |
Scroll vs tap misdetection | ScrollView gesture handling |
$inspect() content check |
AI visibility regression | Entity state not updating |
| CSS-scaled canvas touch | Coordinate mapping broken | WebAdapter v0.2.14 (CSS→logic scaling) |
Scene level:
| Test type | What it catches | Example |
|---|---|---|
SceneRouter push + touch at old scene |
Paused scene receives events | Issue #6 (hitTest穿透) |
| Lifecycle sequence tracking | onResume/onPause missing logic | Roguelike map state not refreshed |
$fixedUpdate deterministic replay |
Physics divergence between sim and game | Autopilot trajectory mismatch |
| Direct module import tests | Physics reachability / balance | Star Drift 50-level validation |
When targeting WeChat/Douyin Mini Games (platform: 'wx' or 'tt'), the framework handles these automatically:
| Handled by framework | Web | Mini Game |
|---|---|---|
| Touch events | addEventListener | wx.onTouchStart |
| Storage | localStorage | wx.setStorageSync |
| Audio (file) | HTMLAudioElement | wx.createInnerAudioContext |
| requestAnimationFrame | window.rAF | global rAF |
| Image loading | loadImage() |
loadImage() (uses wx.createImage internally) |
| roundRect | native | polyfilled with arcTo |
| Image constructor | native | polyfilled to wx.createImage |
| performance.now | native | polyfilled to Date.now |
| Offscreen canvas | document.createElement | createOffscreenCanvas() |
You must avoid these in game code:
| Don't use | Use instead | Why |
|---|---|---|
new Image() |
loadImage(src) from @lucid-2d/engine |
Image doesn't exist in Mini Games (polyfilled but loadImage is safer) |
document.createElement('canvas') |
createOffscreenCanvas(w, h) from @lucid-2d/engine |
No DOM in Mini Games |
performance.now() |
Safe — framework polyfills it | But prefer framework timers |
navigator.*, window.* |
Framework adapters | Not available |
ctx.roundRect() directly |
Safe — framework polyfills it | But be aware when debugging |
ctx.shadowBlur |
Avoid on iOS Mini Games — renders as large bright blocks instead of soft glow. Use multiple semi-transparent strokes for glow effects. | iOS WeChat Canvas shadow implementation differs from standard |
osc.connect(gain).connect(dest) |
osc.connect(gain); gain.connect(dest) |
iOS WeChat connect() returns undefined, breaking chains |
Known iOS WeChat quirks (not bugs in your code):
setTimeouterrors in WAGame.js console — safe to ignore, it's a WeChat base library issue
In v0.3, SceneNode gained an optional preload() lifecycle hook for async resource loading. If your scene overrides preload() and returns a Promise, router.push() / router.replace() now returns a Promise<void> instead of void.
Impact: Headless tests using createTestApp + app.tick() may break if the scene has preload() — the scene won't be initialized by the time tick() runs.
// Before (v0.2.x) — synchronous push, tick works immediately
app.router.push(new GameScene(app));
app.tick(16); // GameScene.onEnter already called
// After (v0.3.x) — must await if scene has preload()
await app.router.push(new GameScene(app));
app.tick(16); // now safe — preload + onEnter completed
// Or use settle() which handles async automatically
app.router.push(new GameScene(app));
await app.settle(); // ticks until stable (awaits preload internally)No change needed if: your scene doesn't override preload() — push remains synchronous and existing code works as-is.
In v0.3.0–v0.3.1, scenes with async preload() were added to the render tree before preload completed. This meant the game loop could call $update() on a scene whose onEnter() hadn't run yet, causing crashes on uninitialized state.
v0.3.2 fix: The scene is NOT added to the render tree until preload completes. During preload, the old scene continues rendering (visual continuity) and interaction is frozen. You can safely remove any if (!this.xxx) return guards that were workarounds for this issue.
See Quick start for the new boot() entry point. Manual createApp() + app.start() still works.
Lucid works best for UI-heavy games (card games, shops, check-ins, quizzes) where most elements are UINodes — $inspect, $query, and headless testing work out of the box.
For physics/action games, the framework provides the skeleton (app lifecycle, scene routing, particles, vectors, $fixedUpdate) but your core game objects (ships, planets, projectiles) will likely live outside the UINode tree, rendered directly via ctx in draw(). In this case:
$inspect()won't see game objects automatically — expose state via$inspectInfo()override- Design scenes to separate logic from rendering for headless-friendly architecture
- Use
$fixedUpdatefor deterministic physics, not$update - For pure logic validation (reachability, balance), import game modules directly instead of using
batchSimulate
See the "Pure logic simulation" section under AI-driven level design for the recommended pattern.
Used Lucid in a real project? We'd love to hear about your experience — what worked, what didn't, what's missing. Your feedback directly shapes the framework's development.
Open an issue with the feedback label: GitHub Issues
MIT