NES rendering experiment that does the wrong thing on purpose: it draws with DOM + CSS layers instead of a framebuffer canvas.
No <canvas> in CSS mode. No WebGL. Just tiles, spritesheets, transforms, and a browser layout engine pretending to be a PPU compositor.
It is absolutely not the normal way to render an NES. It does work.
Current implementation status in this repo:
- jsnes drives CPU/PPU emulation.
CSSRendererrenders from extracted PPU state (nes.ppu) into layered DOM.- Optional
Canvas Moderenders fromppu.bufferfor side-by-side sanity checks. - Includes debug overlays, inspector panels, timing-trace plumbing, and test harnesses.
The npm package name is currently css-snes (package.json), while the app/UI label is CSS-NES.
3,840pre-created BG tile nodes (4 x 32 x 30)64sprite nodes +128sprite-half child nodes (for 8x16 support)12logical spritesheet slots inTileCache(4 BG + 8 SPR)5debug overlay types4inspector panels (NT, Palette, OAM, CHR)1runtime stylesheet (#tile-cache-styles) rewritten as tiles/palettes update
npm install
npm run devOpen the app, then drag/drop a .nes ROM or use the Load ROM button.
| Key | NES Button |
|---|---|
| Arrow keys | D-pad |
Z |
A |
X |
B |
| Right Shift | Select |
| Enter | Start |
Pause/Resumebutton pauses emulation.Stepruns one frame while paused.Canvas Modetoggles CSS renderer vs canvas reference.
- Layers:
B(BG),S(sprites) - Debug overlays:
1grid,2sprite boxes,3palette regions,4split line,5nametable seams - Inspector panels:
Nnametable,Ppalette,OOAM,CCHR
Because "what if Chrome DevTools could inspect live NES tiles and sprites as real DOM elements?" was a question that deserved a practical answer and a mildly irresponsible renderer.
- BG layer with 4 nametable quadrants (
32x30tiles each), diff-based tile updates. - Sprite layer for 64 sprites with
8x8and8x16handling. - Tile cache that builds PNG spritesheets and rewrites a runtime stylesheet.
- BG set caching keyed by pattern-table base + CHR signature (supports multiple active BG sets for region rendering).
- PPU write tracing (
$2000/$2001/$2005/$2006+ optional mapper writes) and scanline state model. - Region planner + region BG compositor (
BGRegionLayer) for split-scroll style scenes (currently capped to 2 regions). - Annotation popover while paused:
- click tile/sprite for metadata + CHR/palette view
- shift+click for per-pixel provenance
- Inspector side panel:
- Nametable minimap
- Palette viewer
- OAM table with hover highlight
- CHR pattern-table viewer
- Stats counters in UI: FPS, DOM mutation count, DOM node count, visible sprite count, sheet regeneration count.
nesDebug.showTileGrid()
nesDebug.showSpriteBoxes()
nesDebug.showPaletteRegions()
nesDebug.showScrollSplit()
nesDebug.showNametableSeam()
nesDebug.toggleAll()
nesDebug.highlightPalette(2)
nesDebug.annotate // AnnotationPopover instance
nesDebug.state // latest extracted PPU state
nesDebug.nes // jsnes instancenes.frame()
-> PPUWriteTracer (optional timing trace)
-> PPUStateExtractor.extract()
-> CSSRenderer.renderFrame()
-> PaletteManager
-> TileCache
-> BGLayer or BGRegionLayer
-> SpriteLayer
-> DebugOverlay
-> Inspector panels
The key idea is simple: extract structural PPU state, then let CSS positioning + layering do compositing work that would usually happen in a framebuffer loop.
css-nes/
โโโ index.html
โโโ styles/nes-layers.css
โโโ src/
โ โโโ app.js
โ โโโ css-renderer.js
โ โโโ ppu-state-extractor.js
โ โโโ ppu-write-tracer.js
โ โโโ scanline-state-builder.js
โ โโโ scroll-region-planner.js
โ โโโ palette-manager.js
โ โโโ tile-cache.js
โ โโโ bg-layer.js
โ โโโ bg-region-layer.js
โ โโโ sprite-layer.js
โ โโโ debug-overlay.js
โ โโโ annotation-popover.js
โ โโโ mutation-counter.js
โ โโโ nametable-viewer.js
โ โโโ palette-viewer.js
โ โโโ oam-viewer.js
โ โโโ chr-viewer.js
โโโ tests/
โโโ unit/
โโโ dom/
โโโ e2e/
npm test
npm run test:e2eLatest local run in this workspace (2026-02-27):
npm test: 11 files, 114 tests passednpm run test:e2e: 5 Playwright tests passednpm run build: production build succeeded
E2E tests use ROMs from roms/ and compare CSS output against a canvas reference (pixel diff thresholds vary by scenario).
- Region timing is scanline-level modeling, not cycle-accurate.
- Region compositor currently uses at most 2 vertical regions.
- Sprite priority vs BG is approximated with z-index, so per-pixel NES priority behavior is not exact.
- No audio output (audio samples are discarded).
Powered by jsnes (jsnes@1.2.1), created by Ben Firshman and maintained by contributors (Apache-2.0).
jsnes does the actual emulation work (CPU/PPU/mappers/input/audio plumbing). This project reads jsnes PPU state and renders it as DOM/CSS layers, plus debugger-style inspection tools.