ARCHITECTURE
NES in the browser: not 60 frames, 60.0988
NES hardware produces 60.0988 frames per second; a browser draws at the display's own pace. The decisions we made between those two clocks while building Kaset: keeping the core swappable, moving rendering off the main thread, and treating the composite signal as the medium the games were designed for.
The first number you meet when building a NES player for the browser is not a round one. The console does not produce 60 frames per second; the NTSC NES runs at 60.0988 Hz. The display, meanwhile, draws at its own pace.
Kaset is a NES player that runs in the browser: you drop a .nes file onto the window, the game runs on your device, and the file never goes to a server. This note records the decisions we made building it — keeping the core swappable, moving rendering off the main thread, and treating composite video not as a stylistic option but as the medium the games were designed for. The whole chain starts with those four digits after the decimal point.
One number: 60.0988
The gap is easy to make concrete. At 0.0988 frames per second it comes to roughly 356 frames an hour, a ratio of about 0.165 percent. Put another way, every ten seconds or so the NES produces one frame more than a 60 Hz presentation can show.
That surplus has to be written down somewhere. Drop the frame and motion stalls for an instant; accumulate it on the audio side and picture and sound drift apart. Every decision below circles the same question: where does the difference get recorded.
Where the clock comes from
The number was not chosen; it falls out of a division. On NTSC the master clock has to be six times the colour subcarrier, and that requirement produces two odd figures: the master clock is by definition 236.25 MHz divided by eleven, or 21.477272 MHz. The PPU spends four of those ticks on every dot and the CPU spends twelve — which is why exactly three PPU dots fall inside one CPU cycle on NTSC.
master 21.477272 MHz (236.25 / 11)
dot 21.477272 / 4 = 5.369318 MHz
CPU 21.477272 / 12 = 1.789773 MHz
frame 341 dots x 262 lines = 89,342 ticks The frame geometry is not a design choice either; it comes straight out of the signal. Of the 262 lines, 240 are visible, one is a post-render line, twenty are vertical blanking and one is pre-render. On PAL the same chain runs from a 26.6017125 MHz master clock through 312 lines, and there 3.2 PPU dots fall inside a CPU cycle.
One dot shorter on odd frames
The detail that fixes the last digits sits here. With rendering enabled, every odd-numbered PPU frame is one PPU clock tick shorter than normal; the skip is performed by jumping straight from dot (339,261) to (0,0), so the dot that goes missing is (340,261). With rendering disabled there is no skip at all and every frame runs the full 89,342 ticks.
The arithmetic follows. When frames alternate between 89,342 and 89,341 ticks the average is 89,341.5, and dividing the dot rate by it gives 60.09881 Hz; without the skip it would come to 60.09848. NESdev publishes a single value in its table: 60.0988. What separates those last digits is one clock tick dropped every other frame.
On the Kaset side frame pacing is measured: an effectiveFps value on the frameTiming object is evaluated against 60, with two separate thresholds for deviation. The console's own 0.0988 sits far below both — the measurement carries the hardware's deviation without treating it as noise.
Which clock keeps the beat
The browser offers two sources of time, and neither is the console's. On the display side requestAnimationFrame runs the callback before the next repaint; the call rate usually matches the display's refresh rate, and in background tabs most browsers pause it. On the audio side the Web Audio specification is explicit: the time elapsed on currentTime belongs to the audio stream and may not be in sync with other clocks in the system.
The audio clock also shifts from device to device. Where no option is given, the sample rate is whatever the output device prefers — typically between 8,000 and 96,000 Hz, most commonly 44,100. baseLatency and outputLatency vary by platform, and latencyHint is a request the browser may decline to honour.
The processing side moved as well. AudioWorklet runs processing code on a separate Web Audio thread, and process() is called once per audio block; blocks are currently always 128 frames long, though the size is meant to be read fresh each time. It replaced ScriptProcessorNode, which ran on the main thread.
The conclusion is straightforward: neither the display clock nor the audio clock gives you 60.0988. Whichever you make the source of tempo, a reconciliation gap remains against the other, and you need somewhere to carry it.
Making the core swappable
Kaset offers two core families: TetaNES (Native) and Libretro. The choice travels in the kaset.core key in localStorage and in a ?core= parameter, and the interface states that a change takes effect on the next cartridge.
That "next cartridge" note is not a usability compromise; it is the shape of the contract. libretro is a lightweight, C-based interface that exposes audio, video and input callbacks in a generic form, and its API version is still 1. retro_load_game loads the content, each call to retro_run produces exactly one video frame, and the frontend learns the audio and video characteristics through retro_get_system_av_info. Swapping a core is not flipping a flag inside a running process; it is standing the contract up again from the beginning.
The native path is Rust. TetaNES is a cross-platform NES emulator that also runs in the browser through WebAssembly, split across two crates, with tetanes-core as an emulation library independent of any interface. That separation is what lets us put our own rendering layer on top. The build target is wasm32-unknown-unknown, the leanest WebAssembly target — it imports no functions from the host — and sits at Tier 2. As of 11 August 2026 the latest published tetanes-core is 0.15.0, dated 7 August 2026, licensed MIT or Apache-2.0.
The libretro path runs through Nostalgist.js. The library ships no emulator of its own; it drives RetroArch's cores compiled with Emscripten, and its single entry point is launch({ core, rom }). The core option takes either a known name or a { name, js, wasm } object, which is where the list stays open-ended. That is also the provenance of the NESTOPIA CORE label in the top bar: Nestopia is a cycle-accurate NES and Famicom emulator, the libretro port builds on the upstream Nestopia JG fork, and it is licensed GPLv2.
The two paths sit apart in the bundle, loading as their own chunks: nativeWorkerEngine, nativeEngine, nostalgistEngine, crtParams and pacing. A version of the same separation appeared in the note "The local LLM serving layer: vLLM, SGLang, llama.cpp and Ollama" — there too, choosing a runtime and preserving the portability of that choice were two different jobs.
Moving to a worker, and what isolation costs
Taking rendering off the main thread runs into a capability test at the door. Kaset requires all five conditions; with everything present it loads the worker-based engine, and otherwise the main-thread one.
Worker
OffscreenCanvas
HTMLCanvasElement.prototype.transferControlToOffscreen
self.crossOriginIsolated === true
new SharedArrayBuffer(4) Those five are really two chains. On the canvas chain, transferControlToOffscreen hands rendering control to an OffscreenCanvas object; the element on the page becomes a placeholder, its intrinsic size becomes fixed, and it can no longer obtain a drawing context of its own. The transfer is one-way and one-time — calling it on a canvas that already has a context, or that has already been transferred, raises InvalidStateError.
The shared-memory chain asks for more. SharedArrayBuffer requires the document to be in a secure context and cross-origin isolated, and isolation is switched on by two headers from the server: Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. The result reads out in code as self.crossOriginIsolated. In return, SharedArrayBuffer objects can travel through postMessage and Performance.now offers finer resolution.
The cost is equally plain. COOP same-origin means the document shares its browsing context group only with same-origin documents; under require-corp, resources fetched in no-cors mode must either be same-origin or grant permission explicitly through Cross-Origin-Resource-Policy. If you are in the habit of dropping third-party scripts onto the page, this choice asks you to tidy up first.
One assumption worth correcting: moving to a worker does not mean giving up the display clock. requestAnimationFrame exists inside dedicated workers too (Baseline since March 2023), provided the worker has an owner window. Atomics.wait, on the other hand, cannot be used on the main thread and only works on arrays backed by SharedArrayBuffer — pacing by parking a thread is available on the worker side only.
Composite is not a look, it is the medium
The real decision in the video layer is conceptual rather than technical. The NES PPU does not produce RGB and then convert it to composite; it builds NTSC video directly in the composite domain. Composite video is not a filter added afterwards — it is the signal itself.
The palette makes that concrete. A six-bit value maps to one of 64 outputs, where the top two bits set brightness and the lower four largely set hue. Hue here is a subcarrier phase: values from $x1 to $xC are a square wave swinging between two voltage levels. Colour travels as timing, not as a number.
One consequence follows directly: there is no single correct palette. On real hardware the palette has at least four sources of variation — impedance matching, the television's user settings, how it decodes composite into RGB, and the set's own colour space. In NESdev's own words, no single composite palette satisfies every game's intended appearance at once, and Nintendo never described a reference monitor to its licensed developers. That is precisely why we offer four profiles rather than one.
Colour resolution falling below pixel resolution comes from the same place. A colour cycle lasts twelve clocks while an NTSC pixel is eight clocks wide, so part of the colour information is shared with the neighbouring pixel. Because a scanline carries 227⅓ colour cycles, the alignment shifts on every line and a pattern repeats every three lines — visible as shimmer during slow scrolling.
The four profiles rest on those facts. How composite gets decoded varies from set to set, and some sets do not filter at all — that is where Living Room TV comes from. A Trinitron tube uses a single electron gun, striped phosphor and an aperture grille as its colour selector, the grille being strips formed by vertical slits in a thin sheet. A shadow mask is a perforated plate that shadows phosphor triads instead. The two leave different visible structures: one a triad of dots, the other an unbroken vertical line. That is why the mask setting in our parameters carries a kind field at all.
The RF profile rests on bandwidth. A television channel is 6 MHz wide in total, while the colour-difference channels travel between a few hundred kHz and 1.3 MHz — colour rides in a far narrower band than luminance. Scanlines are visible for a different reason: the NES always produces 262 lines, so the television draws fields on top of each other and no interlaced picture forms.
The numbers behind the profiles are values we chose; they do not come from a calibration measurement. In the colour layer, saturation 1.25, contrast 1.06, gamma 1.05 and a slightly warm tint multiplier; in the scanline layer, a beam width around 0.55; in the mask layer, a shadow type at 0.25 strength and scale 3. The WebGL CRT layer is enabled on the native core only.
Region, save states, and where the file stays
Region is not a label, it is a different machine. A PAL frame is 312 lines at 50.0070 Hz, with a pixel aspect ratio of 1.386:1 against 1.143:1 on NTSC. The colour artefact pattern changes too: a PAL line carries 284⅙ chroma cycles, so the pattern repeats every six lines rather than three.
There is a hybrid in between. Dendy is a famiclone that uses a PAL signal while its CPU runs as fast as an NTSC one, combining PAL frame geometry with the NTSC CPU-to-PPU ratio. CPU cycles per frame come to three different numbers across the three systems: 29,780⅔ on NTSC, 33,247.5 on PAL and 35,464 on Dendy. Kaset offers Auto, NTSC and PAL in the interface, and in code PAL and Dendy map to the same side while everything else maps to NTSC.
On the save-state side, a state is bound to the ROM: the output of saveState is stored together with a romHash, a slot, the file name and a creation time, and the operation runs inside a five-second timeout wrapper. Binding a save to a hash of the content rather than to a file name is a question of identity — so that different copies of the same game do not land on each other's slot.
What is left at the end returns to the first sentence. The file you drop into Kaset does not leave your device; the game runs on your machine. Every decision in this note is a different form of the same question: knowing exactly where the work is happening.
Sources
- NESdev Wiki — Clock rate, Cycle reference chart (master clock, CPU and PPU divisors, cycles per frame)
- NESdev Wiki — PPU frame timing, PPU rendering (the dot skipped on odd frames and where the skip happens)
- NESdev Wiki — NTSC video, PAL video (colour generator, colour cycle width, chroma cycles per line, 240p)
- NESdev Wiki — PPU palettes (the six-bit palette value, hue as subcarrier phase, sources of palette variation)
- NESdev Wiki — Detect TV system, iNES, NES 2.0 (Dendy, CPU cycles per frame, region fields in the header)
- MDN Web Docs — SharedArrayBuffer, Window and WorkerGlobalScope: crossOriginIsolated
- MDN Web Docs — Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers
- MDN Web Docs — OffscreenCanvas, HTMLCanvasElement: transferControlToOffscreen(), Atomics.wait()
- MDN Web Docs — Window and DedicatedWorkerGlobalScope: requestAnimationFrame()
- MDN Web Docs — AudioWorklet, AudioWorkletProcessor: process(), AudioContext baseLatency and outputLatency, BaseAudioContext: sampleRate
- WHATWG HTML Standard — The canvas element (placeholder canvas behaviour)
- W3C Web Audio API — BaseAudioContext.currentTime (the audio stream's own time)
- lukexor/tetanes — repository and README; crates.io — tetanes-core release list
- Nostalgist.js documentation — Under the hood and launch
- libretro documentation — Developing Cores and the Nestopia UE core; libretro-common — libretro.h
- Rust compiler book — platform support: wasm32-unknown-unknown
- Sony US5382871A and US6111349A (aperture grille), Zenith EP0239083A2 (shadow mask)
- 47 CFR 73.682 — TV transmission standards (channel width and the colour-difference band)