The Super Nintendo's CPU is a 65816 running at about 3.58 MHz. It has a handful of registers, 128 KiB of work RAM (only 8 KiB of it in the fast "low RAM" window), no floating point, and a hardware divider you have to wait on. It's not an obvious target for TypeScript.
I built SNES Studio, a browser editor where you write a game in TypeScript, press Play to run it instantly, and press Export ROM to get a real .sfc file that runs in emulators like Mesen and bsnes, and on a real console with a flash cart. This post is about how the compiler side works, and mostly about the design decision that made it possible: deciding what not to support.
One source, two runtimes
A game's code lives in .ts files under scripts/. main.ts exports two functions: start(), run once, and update(), run every frame, 60 times a second.
import { held, sprite, LEFT, RIGHT, type s16 } from 'studio';
let x: s16 = 120;
export function start(): void {}
export function update(): void {
if (held(LEFT)) x--;
if (held(RIGHT)) x++;
sprite('player', x, 104);
}
That same file has two lives:
- In the browser, it runs as ordinary TypeScript. Play starts immediately, with no build step, and the editor gives you type checking and completion as you type.
- For the cartridge, the same file compiles to 65816 assembly.
The hard part is making those two lives agree. If the browser preview and the cartridge give different answers, the preview is a lie. So the test suite runs compiled code next to the TypeScript, on an emulated 65816 and in Mesen, and the two must produce the same results.
That requirement drives almost every rule in the language.
Rule 1: every value has a width
Plain number is refused. Every value is an integer of a known size, or a name:
| Type | Range |
|---|---|
u8 / s8
|
0..255 / -128..127 |
u16 / s16
|
0..65535 / -32768..32767 |
s32 |
32-bit signed |
bool |
true / false |
These are TypeScript type aliases, so the type checker is happy and the browser runs them as normal numbers. The compiler, though, knows exactly how many bytes each one needs and which instructions to emit.
There are no fractions. 1.5 is a compile error. The answer is the same one SNES programmers have always used: fixed point. Keep a position in sixteenths of a pixel in an s32 and shift it down when drawing.
Constants have to fit their type too. const BIG: u8 = 300 is refused, because the browser would keep 300 while the cartridge would keep 44.
Rule 2: strings are names
Strings exist, but only as enums. A union of string literals becomes a small integer on the cartridge:
type Mood = '' | 'calm' | 'angry';
let mood: Mood = 'calm';
if (mood === 'angry') sound('roar');
You can compare, assign, pass and switch on them. There's no concatenation and there are no string methods. This turns out to be a nice fit for TypeScript: the project's own assets become types as well. A sprite name, a scene, a song, a background are all string-literal unions generated from the project's files, so a misspelled picture name is a type error before you ever press Play.
Rule 3: no recursion, no closures, no allocation
On a modern machine, locals live on a stack and objects live on a heap. On a 65816 with 8 KiB of low RAM, both are expensive and hard to reason about.
So the subset has no recursion and no closures. The compiler checks the whole call graph, and since no function can be on the stack twice, every function's locals get a fixed place in RAM. There's no new at runtime either, apart from fixed-size arrays and typed arrays declared up front.
Data falls into two buckets:
-
ROM tables:
readonlyarrays,Records keyed by a string-literal enum,Sets of names, and arrays of records described by aninterface. These are packed to their element type, so au8table costs a byte per entry. -
RAM arrays: mutable arrays and
Uint8Array/Uint16Arraybuffers with a constant size. A large buffer can be pinned outside low RAM with an annotation:
// @at 0x7F2000
const trail = new Uint8Array(4096);
It's narrow in the same way C on a 16-bit console is narrow. In return you get exact integer types, enums for free, tables checked by the type checker, and one source that runs in two places.
Rule 4: arithmetic you can predict
Integer promotion follows rules close to C's. An operation is computed in the width of its widest operand, and in at least 16 bits. A constant takes the other operand's type when it fits. Comparing a signed with an unsigned value is done in 32 bits, so a u16 above 32767 never looks negative.
The compiler also tracks the range of each value. When a value that's wanted in 32 bits provably fits in 16, it's computed in 16 bits and then extended. So something like x += facing * SPEED on an s32 x costs a 16-bit multiply, not a 32-bit one, which matters a lot on this CPU.
Rule 5: say how you want to divide
/ and % are refused outright. TypeScript divides as fractions and the cartridge divides as integers, so the real question is how to round. You pick:
| Call | Rounds |
-7 by 2
|
|---|---|---|
divT |
toward zero | -3 |
divF |
down (floor) | -4 |
divRound |
nearest, halves away from zero | -4 |
modF |
remainder of divF
|
1 |
That explicitness also helps code generation. Dividing by a power of two with divF becomes a shift, modF becomes a mask, and a non-negative 16-bit value divided by a constant byte uses the console's hardware divider.
The one place they still disagree: overflow
TypeScript doesn't wrap and the cartridge does. A u16 incremented past 65535 keeps growing in the browser and goes back to 0 on the console. I couldn't make that go away without slowing every addition down, so the language treats overflow as a game bug and gives you tools to avoid it: saturate instead of wrapping, compare against constants instead of subtracting, and use toU8, toS16 and similar helpers when you really do want to truncate, which behave the same in both places.
score = score > 0xffff - points ? 0xffff : score + points;
The escape hatch: hand-written assembly
Sometimes a hot loop really needs to be written by hand. A function marked // @native keeps its TypeScript body, which the browser runs, while the cartridge calls an assembly routine from a .s file next to the script. The same parity tests hold the two versions to the same results, so the preview stays honest.
// @native
export function twice(n: u16): u16 {
return n * 2;
}
And when you need the hardware itself, a 'snes' module exposes registers, DMA, HDMA and raw ROM and RAM access.
Refusing loudly
Everything outside the subset is refused with a file and line: floats, Math, general strings, Map, array methods, spread, destructuring, optional chaining, exceptions, generics, classes and namespaces. The important part is that nothing is quietly compiled into something that behaves differently. If the compiler accepts it, the browser and the cartridge should agree.
Measuring it
I keep a speed page that times 12 small programs, in master clocks on the engine's 65816 core, against hand-written 65816 assembly, with every version required to give the same answer. Across those programs, the compiled TypeScript currently takes about 1.04x the time of the hand-written versions (geometric mean).
The page also includes the same programs in C, built with cc65 (cc65 --cpu 65816 -Oirs -Cl), since C is what most people would reach for on this hardware. On this set the cc65 builds take about 5.63x the hand-written time. That's not a knock on cc65: it's a mature compiler for the whole 6502 family, and it has to handle real C, recursion and pointers included. SNES Studio wins here mostly because of everything the rules above refuse. A narrow language gives the compiler fixed RAM for every local, exact widths and no aliasing surprises, and that's where the speed comes from.
It's a benchmark set, not a promise about every program, but it's how I check whether a change to the compiler actually helped.
What I learned
The biggest lesson was that a restricted language is a feature. Every rule above exists either because the 65816 can't do something cheaply or because the browser and the cartridge would disagree. Once those rules were written down (the full language page is here), both the compiler and the people writing games had a much clearer contract.
On process: I used generative AI heavily for code, sound and music, and text. The architecture, the language design and the decisions are mine, and every change is checked by building a ROM and running it.
You can try SNES Studio for free in the browser at snes-studio.pages.dev, with the API reference at /api. Our own games are written in SNES Studio's TypeScript as well. The SNES DEV Game Jam 2026 ones, Super Wolf, Tiny Run and Space Blasting, are free at vsgamesretro.itch.io.
If you've worked on compilers for small or old targets, I'd love to hear what you would have done differently.
Top comments (0)