performance
Skill1 starsChanged 49 days agoMITView on GitHub
Measure and fix runtime performance of games on this TypeScript Canvas 2D engine (WeChat / Douyin / TapTap mini-games and web). Covers fps and frame time (game.stats, update/render ms, worst frame), node and particle counts, texture memory (textureStats, bakeTexture resolution 'auto', atlases, releaseTexture), the headless `pnpm bench` benchmark, the debug overlay (showDebugOverlay, ?stats=1 / ?debug=1, node bounds, hit areas) and one-frame debugDraw for physics and gameplay debugging, plus mini-game budgets, costly canvas operations and a release checklist. Use when a game stutters, drops frames or overheats (性能, 卡顿, 掉帧), uses too much memory or crashes on low-end Android (内存, 贴图内存), before a release, when baking textures or picking a resolution, when adding many nodes or particles, or when you need profiling numbers or on-screen debug drawing.
81 other projects use a version of this.
Adopted by
Most recently taken up by
- aazirani/flutter_agentifytheir own version, first seen Nov 15, 2026
- JeffKarling/Anviltheir own version, first seen Nov 15, 2026
- FimawLLC/fimaw-skillstheir own version, first seen Nov 15, 2026
- theexperiencecompany/gaiatheir own version, first seen Nov 13, 2026
- TheBeardedBearSAS/claude-crafttheir own version, first seen Nov 11, 2026
- plugin87/ux-ui-agent-skillstheir own version, first seen Nov 10, 2026
Used with
Projects with it also use app-brief, design-from-app and 10 more.
Other files people use alongside this one.
Files found in the same public projects as a version of this one, more often than they turn up anywhere else. Each project counts once, large collections count for less, and this file's own repository is left out.
StijnusSkillapp-brief
One-page brief (docs/BRIEF.md) for a new macOS app, written before any code. Use when the user has an app idea, wants to replace an app they pay for, or the research and clone skills are done.
Also in 3 projects that use this
StijnusSkilldesign-from-app
Rebuild the look of an app the user likes as a native SwiftUI design kit, by measuring its screenshots (sizes, spacing, type, colors, icons), building its parts, and comparing pixel by pixel until they match. Use when the user names an app whose look they want, shares screenshots of one, or says "make it look like X".
Also in 3 projects that use this
FlorianBruniauxSkilltdd-rust
Also in 3 projects that use this
RodrigoTomeESSkillweb-quality-audit
Comprehensive web quality audit covering performance, accessibility, SEO, and best practices. Use when asked to "audit my site", "review web quality", "run lighthouse audit", "check page quality", or "optimize my website".
Also in 9 projects that use this
launchapp-devSkilllayout-grid
Define responsive layout grid systems with columns, gutters, margins, and breakpoint behavior.
Also in 3 projects that use this
MohamedAbdallah-14Skillapp-icon
Generate an app icon from a single 1024² master and fan out to iOS (AppIcon.appiconset with squircle-ready 1024 opaque), Android (adaptive foreground + background + Android 13 monochrome), PWA (192/512 + 512 maskable with 80% safe zone), and visionOS (3-layer parallax).
Also in 3 projects that use this
- +1 more from Stijnus
Other versions81 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 81
- rtk-ai/rtk★ 87ksame name
- millionco/react-doctor★ 15ksame name
- midudev/preguntas-entrevista-react★ 7.8ksame name
- BuilderIO/agent-native★ 7.7ksame name
- ethereum/ethereum-org-website★ 6ksame name
- plugin87/ux-ui-agent-skills★ 3.2ksame name
- static-web-server/static-web-server★ 2.4ksame name
- daveebbelaar/bama★ 1.7ksame name
- lichtblick-suite/lichtblick★ 1.2ksame name
- theexperiencecompany/gaia★ 327same name
71 more not listed
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository11 other files it gives its agents.
- AGENTS.mdAGENTS.md
- audio-designSkill
- code-artSkill
- engine-extendSkill
- game-feelSkill
- input-and-i18nSkill
- make-a-gameSkill
- physicsSkill
- release-platformsSkill
- testing-and-shotsSkill
- ui-screensSkill
The file
We split this skill into the rules it gives an agent
4 rules. 0 are common, 1 is distinctive, and 3 are very specific to this project.
0
Common
Most of the 54 edited versions we read keep these.
1
Distinctive
This author's own choices. Worth a look.
3
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 28
Nodes: every drawn node costs save / transform / draw / restore.
- Distinctive: line 94
Not tracked: CacheContainer and TileMap chunk canvases (their size is roughly the cached area at screen resolution).
- Very specific: line 221
Gradients: create them once (bake or cache), not in every draw().
- Very specific: line 232
Look at the scene with the overlay: showDebugOverlay(t.game, { bounds: true, hits: true }) in a test or shot, or ?debug=1 in the dev server.
“Common” is measured: kept by at least 6 in 10 of the 54 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Performance: measure, budget, fix
Targets are memory-sensitive: low-end Android phones running WeChat / Douyin mini-games kill the process when memory runs out and have slow CPUs for Canvas 2D. Always measure before and after a change. Three tools:
| Tool | What it tells you | Where |
pnpm bench | update / render ms (avg, p95, max), nodes, particles, texture MB, heap delta, headless | terminal |
game.stats + textureStats() | the same numbers from code (tests, logs, window.__engine) | any code |
showDebugOverlay(game) | on-screen panel with fps, worst frame, ms graph, bounds, hit areas, debugDraw | device / browser / shots |
Budgets (mini-games, low-end Android first)
- Package: wx / tt main package <= 4 MB (
pnpm build --target wxfails above it and prints a size table). Code-generated art and audio keep the package small; heavy assets go to subpackages. - Texture memory (decoded RGBA = 4 bytes per backing pixel,
textureStats().mb): aim for <= 30 MB of baked + loaded textures in total, treat 50 MB as the hard ceiling on 2-3 GB Android phones. The screen canvas itself is extra (1080x2400 backing = ~10 MB). Keep single canvases <= 2048 px per side. A 1024x1024 texture is 4 MB; baking it at resolution 2 makes it 16 MB. - Frame time: 16.7 ms per frame at 60 fps includes the host's own work, so keep JS time
(
game.stats.frameMs, update + render) <= 8 ms on a mid phone. Worst frame (intervalMs.max) above ~50 ms is a visible hitch (usually GC from per-frame allocations, or a synchronous bake mid-game). - Nodes: every drawn node costs save / transform / draw / restore. Keep drawn nodes per frame
(
stats.drawnNodes) under ~500-800 on low-end phones; cull off-screen content (World culls children), cache static groups (CacheContainer). - Particles: each live particle is a draw call. Keep live particles (
stats.particles) under ~300 in total;maxParticlesdefaults to 200 per emitter, lower it for ambient effects.
Measure
pnpm bench (headless, relative numbers)
pnpm bench --app game --scene play --taps random --seconds 20
pnpm bench --app sandbox --scene debug-overlay --device ipad --jsonOptions: --app <dir> (default: package.json engine.app), --scene, --params '<json>',
--device <name|WxH@dpr> (default iphone-14), --seconds 10, --warmup 1, --taps none|random
(random taps in the lower 2/3 of the view), --tap-every 0.8, --seed 1, --json.
NODE_OPTIONS=--expose-gc makes the heap delta steadier.
It runs the scene frame by frame with game.update(1/60) + game.render() and prints update / render /
frame ms (avg, p95, max), nodes, drawn nodes, particles, texture count + MB with the five largest textures, and
the heap delta. Rendering is @napi-rs/canvas on the CPU, so numbers are relative: compare runs of the same
command on the same machine (before / after a change). They are not device fps. Texture MB, node and particle
counts are exact and platform independent.
game.stats (from code)
The Game loop fills game.stats every frame; update() and render() are timed separately, so harnesses that
call them one by one still get numbers. Reading is cheap (summaries are computed on demand).
import { formatGameStats } from '@engine';
const s = game.stats;
s.fps; // smoothed over ~0.5 s of frame intervals
s.frameMs.avg; s.frameMs.max; // JS ms per frame (update + render), rolling 120-frame window
s.updateMs.percentile(0.95); s.renderMs.max;
s.intervalMs.max; // worst frame interval in the window (hitches)
s.nodes; s.drawnNodes; s.particles; // stage nodes, nodes drawn last frame, live particles
s.textureCount; s.textureMB; // tracked textures (process wide)
console.log(formatGameStats(s)); // one line: fps 60.0 | frame 1.20 ms (max 3.40) | ...
JSON.stringify(s.snapshot()); // plain object for logs / automation
s.reset(); // start a fresh window (e.g. after a scene change)In tests (createTestGame) fps follows the simulated dt (60) and ms come from performance.now().
The harness draws only the last frame of each step/advance call by default, so render counts
(renderMs.count, drawnNodes per frame) need createTestGame({ render: 'every' }).
textureStats (texture memory)
import { textureStats, textureMemory } from '@engine';
const t = textureStats({ top: 10 }); // { count, pixels, bytes, mb, top: [{ key, kind, width, height, bytes }] }
console.log(t.mb.toFixed(1), t.top.map((e) => `${e.key} ${e.width}x${e.height}`));
textureMemory.bytes; // O(1) running totalTracked automatically: bakeTexture (kind 'baked', and everything built on it: Graphics.bake, svgTexture,
shapes, pixelSprite, patterns, creatures), loaded images (kind 'image', keyed by path, counted once even when
used by several keys or sheets) and textures put in the textures registry (atlas pages show as page(<key>)).
Baking again with the same key replaces the old entry. textures.delete(key) / textures.clear() stop
counting a source once no registered texture uses it; garbage-collected sources drop out where the runtime has
FinalizationRegistry. Canvases you create yourself: trackTexture(canvas, 'my-key') / untrackTexture(canvas).
Not tracked: CacheContainer and TileMap chunk canvases (their size is roughly the cached area at screen
resolution).
Free a big texture right away when a scene no longer needs it (mini-game canvases are slow to be collected):
import { releaseTexture, textures } from '@engine';
releaseTexture(bigBackground); // untrack + shrink its canvas to 1x1; nothing may draw it afterwards
textures.delete('level-3-map'); // registry textures: unregister (untracks when unused)Debug overlay
import { hideDebugOverlay, showDebugOverlay, toggleDebugOverlay } from '@engine';
showDebugOverlay(game); // stats panel only (default)
showDebugOverlay(game, { stats: true, bounds: true, hits: true, corner: 'bottom-left' });
showDebugOverlay(game, { bounds: false }); // reconfigure the existing overlay
toggleDebugOverlay(game);
hideDebugOverlay(game);Options: stats (panel: fps + worst frame, frame / update / render ms avg + max, nodes + drawn, particles,
textures + MB, CPU frame-time graph with a 16.7 ms line; default true), bounds (rotated content box of every
rendered node), hits (tap areas incl. hitPadding of nodes that currently receive input: invisible blockers
and too-small buttons show up at once), draw (debugDraw requests, default true), corner, fontSize (22),
refresh (panel text interval, 0.25 s).
The overlay is a DebugOverlay node at the top of game.overlay: never hit-tested (not interactive, no
interactive children), tagged lint-ignore + debug so UI lint skips it, shown in dumps as
DebugOverlay[stats=true].... It draws in design space after the rest of the stage.
- Dev web builds:
?stats=1shows the panel,?debug=1panel + hit areas + debugDraw,?debug=alladds bounds,?debug=stats,bounds,hits,drawpicks parts. The entry callsapplyDebugQuery(game, location.search);applyDebugQuery(game, launchQueryObject)works the same with a mini-game launch query. - In a headless shot or test:
showDebugOverlay(t.game, { hits: true }), thent.screenshot(...)and Read it. - Sandbox demo:
pnpm shot --app sandbox --scene debug-overlay --device iphone-se,iphone-14,ipad --lint.
debugDraw (one-frame debug drawing)
Like Excalibur's ex.Debug: call it from update(), systems or physics; the overlay draws the requests on top
and drops them at the start of the next frame (requests made between frames, e.g. in input handlers, are drawn
once first). Calls cost nothing but a branch while no overlay with draw is shown.
import { debugDraw, type DebugDrawOptions } from '@engine';
// Styles built once: a colour string or a reused options object allocates nothing per frame.
const vel: DebugDrawOptions = { color: '#4ade80', width: 3, space: world }; // coords in world's local space
const range: DebugDrawOptions = { color: '#f87171', space: world };
node.onUpdate(() => {
if (!debugDraw.enabled) return; // skip debug work when nobody draws
for (const b of bodies) debugDraw.arrow(b.x, b.y, b.x + b.vx * 0.2, b.y + b.vy * 0.2, vel);
debugDraw.circle(player.x, player.y, attackRange, range);
debugDraw.text(`contacts ${contacts.length}`, 20, 200, '#ffffff'); // stage (design) space
});
debugDraw.point(tap.x, tap.y, { color: '#f472b6', size: 14, duration: 1 }); // in a tap handler: keep 1 sShapes: line, arrow, rect(x, y, w, h), circle(x, y, r), point, text(text, x, y),
polygon([x0, y0, x1, y1, ...]). Options: color, width (line width, 2), size (point / text size),
fill, space (a Node whose local coordinates you pass; text stays upright), duration (seconds).
Resolution 'auto' (the biggest memory win)
Backing pixels per design unit = game.pixelRatio * game.scale. With the default maxPixelRatio 2 that is
~1.0 on an iPhone SE / iPhone 14 / 720p Android and ~1.5 on an iPad. Baking at a fixed resolution 2 stores 4x
the pixels a phone can show.
import { autoTextureResolution, bakeTexture } from '@engine';
bakeTexture(w, h, paint, { resolution: 'auto', key: 'fruit:apple' }); // 1 on phones, 1.5 on iPad
bakeTexture(w, h, paint, { resolution: 'auto', maxResolution: 1.5, key: 'bg' }); // cap big art
const res = autoTextureResolution(); // same number, for caches or APIs that take a number
const atlas = new TextureAtlas({ width: 1024, height: 1024, resolution: autoTextureResolution() });
bakeFruitArt(autoTextureResolution()); // game code that caches per resolution takes the number'auto' rounds to 0.25 steps, clamps to [0.5, maxResolution] (default max 2) and is valid from the Game
constructor on, so it works inside boot(). It is evaluated at bake time: textures baked at boot keep their
resolution after a web window resize. Art that is usually drawn larger than its logical size (camera zoom,
big pop-in scales) can ask for a margin with a plain number: resolution: autoTextureResolution(3) * 1.25.
Fewer, cheaper draws
- Atlases: many small baked images belong on one
TextureAtlaspage (atlas.add(key, w, h, paint)thennew Sprite(key)): one canvas instead of dozens, less memory overhead per canvas. Checkatlas.occupancy. - CacheContainer for static groups (map decorations, complex Graphics, many labels): renders the subtree
once and blits it; call
markDirty()after changing children,releaseCache()when hidden for long.new CacheContainer(w, h)follows the screen resolution by default. - Bake vector art: a
Graphicswith gradients / many paths redrawn every frame is slower thannew Sprite(g.bake(autoTextureResolution())). - Hide, cull, flatten:
visible = falseskips a whole subtree;Worldculls off-screen children; each drawn node costs onesetTransform(plusglobalAlphaonly when it changes); onlyclip/blend/isolatenodes add a save/restore. - Pools (
Pool,NodePool) for bullets, fruits, floating texts instead of create/destroy every frame. - Frame rate:
game.setFrameRate(30)on menus, pause and result screens (20 for a static title), back to 60 for gameplay; saves battery and heat. dt stays real time.
Costly canvas operations
- shadowBlur / shadows: very slow on CPU canvases and mini-game runtimes, worst when animated or on large
shapes. Bake shadows into textures (Box / Graphics shadow once, then
bake) or useShadowBlob. - Text: layout and measurement are memoized per text + style, so repeated
measure*calls are free, but a new string re-wraps. Update labels only when the value changes (not every frame to an equal-looking string) and avoid many different font sizes.TextStyle.cache(default'auto') draws stroked / shadowed labels and labels unchanged for 30 frames from a shared bitmap, and counters directly; force'none'for per-frame values,'bitmap'for big stroked titles. Bitmaps show astext:*intextureStats();textBitmapStats(), budgetsetTextBitmapBudget(px)(1.5 M px, LRU). Change styles withsetStyle, not by mutating the object. - UI relayout: a label change that keeps its measured size (timers, fixed or grow width) does not relayout
the screen; a size change relayouts the root once. Change UI children via add/remove or
markUILayoutDirty. - Per-frame allocations: object / array literals,
slice/map/filter, closures and template strings inupdate()ordraw()feed the GC and show up as worst-frame spikes. Reuse objects, keep scratch arrays, prebuild debugDraw styles, format numbers only when they change. For per-frame scratch arrays write by index and keep a count:arr.length = 0makes V8 drop the backing store (seeSpatialHash.queryInto). Copythis.countinto a local before a hot loop. Engine helpers takeoutparams (worldMatrix(out),toLocal(x, y, out),input.vector(..., out),followNode(...).into(space, out)); colour strings fromtoCssare cached. - Hot numeric code: doubles passed to or returned from calls V8 does not inline get boxed (allocated); use
scratch objects. No
Math.hypotin hot paths (it allocates in V8 and rounds differently across engines):Math.sqrt(x * x + y * y). - save / restore and state changes: rendering is flat
setTransform; save/restore only forclip,blendorisolate, sodraw()must reset the canvas state it changes (shadows, line dash, lineCap / lineJoin, composite op) or setisolate: true. Clips (Nodeclip, MaskContainer), blends and alpha changes add up. - Gradients: create them once (bake or cache), not in every
draw(). Widgets:uiShade/uiMix/UIGradientCacheinstead ofdarken/lighten/createLinearGradientper frame; passdrawUIBoxthe same props object every frame. - Scaling big images down every frame: bake at display size instead.
- getImageData / putImageData / toDataURL: synchronous GPU readback, never per frame.
- Baking mid-game: bakes are synchronous; bake at boot or on scene enter, not on the first spawn.
Workflow for "it stutters" / "memory is high"
- Reproduce headless:
pnpm bench --app <app> --scene <scene> --taps random --seconds 20and note frame p95 / max, nodes, particles, texture MB. - Look at the scene with the overlay:
showDebugOverlay(t.game, { bounds: true, hits: true })in a test or shot, or?debug=1in the dev server; on a phone watch fps and the worst frame. - Memory:
textureStats({ top: 10 }); switch fixed resolutions to'auto', pack small textures into an atlas, release scene-specific textures on exit. Handlegame.on('memorywarning', ...): the engine already dropped tint and text-bitmap caches and asks the host for a GC; free what the game can rebuild (releaseTexture,TileMap.releaseChunks,CacheContainer.releaseCache). Music streams and unloads 4 s after it stops; sfx preload at boot. - CPU: cut drawn nodes (cull, cache, bake), particles (
maxParticles), shadows, per-frame allocations. - Re-run the same bench command; keep the before / after numbers in the commit message.
Release checklist
pnpm build --target wx(and tt): main package <= 4 MB.pnpm benchof the main gameplay scene: frame p95 not worse than the last release; no max spikes that grow with time (leaks); heap delta small with--expose-gc.textureStats()in the heaviest scene <= 30 MB; baked art usesresolution: 'auto'; the top list has no surprise (old re-bakes, forgotten debug textures).- Drawn nodes and live particles within budget in the busiest moment (
stats.drawnNodes,stats.particles). - Real low-end Android with the stats panel: >= 55 fps in gameplay, worst frame < 50 ms, no growth of texture MB across scene changes.
- No debug overlay in release builds:
showDebugOverlayonly behind a dev flag / launch query; heavy debug geometry behindif (debugDraw.enabled).
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

