The payload
What is actually stored
A contract on an EVM chain has two things at its address: storage, and code. Code is a byte string the chain keeps forever and hands to anyone who asks. Nothing requires it to be a program. If you put a game there instead, the chain keeps the game forever and hands it to anyone who asks.
That is the whole idea. Everything below is the bookkeeping needed to make it usable: how to tell a game apart from a token, how to know which kind of game it is, and how a cartridge says which machine it wants to run on.
Every cartridge is framed the same way. These are the real first sixteen bytes of the
cartridge deployed at 0xdc28337a…20f6, exactly as
eth_getCode returns them:
Why each field is there
The leading 00 is a STOP instruction. Contract
code is callable — someone will eventually send a transaction to a cartridge address, by
accident or on purpose, and the EVM will start executing these bytes as a program.
Starting with STOP means it halts immediately instead of running compressed
game data as opcodes and doing something undefined. It costs one byte and it is the same
guard SSTORE2 uses.
The magic is how a loader tells a cartridge from a token, a proxy or an empty address. Eight bytes is generous, and deliberately so: a false positive here means handing arbitrary bytes to a decompressor and then to an interpreter.
Version and flags are one byte each. Flags bit 0 means the payload is gzipped. Version 1 was a JavaScript payload and nothing else; version 2 added the two fields below it. Loaders still read v1, because bytes on a chain do not get to be migrated.
Kind says what the payload is, and it is what lets one loader serve several kinds of thing:
| kind | payload | what the loader does |
|---|---|---|
| 0 | JavaScript | runs it in a blank sandboxed document |
| 1 | an Atari 2600 ROM | fetches the console named in ext, then runs the ROM on it |
| 2 | a JavaScript console | refuses — a console is a machine, not a game |
The extension is a length byte followed by that many bytes, defined by the kind. Only kind 1 uses it, and what it holds is the twenty-byte address of the emulator the ROM expects. That single field is what makes one address enough to boot a two-contract game.
The same frame can ride in a transaction’s calldata instead of a contract’s
code — the loader reads that too, from eth_getTransactionByHash. In that
form the leading STOP is absent, because calldata is data already and is
never executed. Contract code is still the better home: it is chain state
rather than history, so it survives an RPC that has pruned old transactions or never
indexed them.
Deployment
How a string of bytes becomes a contract
There is no Solidity in this project’s deploy path. Not a compiler, not an ABI, not
a constructor argument. A contract creation transaction has no to address
and its input is init code: a small program the EVM runs once, whose return
value becomes the contract’s permanent code.
So the job is to write the shortest possible program whose return value is “all of me except my own first eleven bytes”. Here it is, in full:
| byte | op | mnemonic | stack afterwards |
|---|---|---|---|
| 0 | 60 0b | PUSH1 11 | 11 |
| 2 | 59 | MSIZE | 11, 0 |
| 3 | 81 | DUP2 | 11, 0, 11 |
| 4 | 38 | CODESIZE | 11, 0, 11, size |
| 5 | 03 | SUB | 11, 0, len |
| 6 | 80 | DUP1 | 11, 0, len, len |
| 7 | 92 | SWAP3 | len, 0, len, 11 |
| 8 | 59 | MSIZE | len, 0, len, 11, 0 |
| 9 | 39 | CODECOPY | len, 0 |
| 10 | f3 | RETURN | — |
CODECOPY takes the top three stack items as destination, source offset and
length: copy the init code from byte 11 onward into memory at 0. RETURN then
takes offset and length and hands that memory back. The EVM writes it to the new address
as code and the transaction is done.
So the transaction that created cartridge #0002 had, as its entire input:
600b59813803809259 39f3 ← 11 bytes of init code 00 ← STOP 48 4f 4f 44 43 41 52 54 ← HOODCART 02 01 01 14 ← v2, gzip, kind 1, ext 20 bytes 67 23 52 e1 … ← the console's address 1f 8b 08 … ← 763 bytes of gzip
Economics
What it costs, and where the cost is
Three prices decide everything. Calldata is 16 gas per non-zero byte and 4 per zero byte. Init code costs a further 2 gas per 32-byte word, under EIP-3860. And the code deposit — the price of the chain agreeing to remember your bytes — is 200 gas per byte.
That last one dominates by a wide margin, and it is the reason the payload is gzipped rather than stored as-is. Compression is not a nicety here; it is most of the bill.
| contract | source | gzip | on chain | gas | mainnet |
|---|---|---|---|---|---|
| console | 14,442 | 5,359 | 5,372 | 1,225,509 | 0.0000821 |
| #0002 ROM | 4,096 | 885 | 918 | 251,404 | 0.0000166 |
| #0001 game | 6,056 | 2,929 | 2,942 | 689,222 | 0.0000461 |
Playing costs nothing
Worth stating plainly, because it is the part people expect to be otherwise: no transaction is sent when someone plays. The game is chain state, and reading state is free and happens entirely on whatever node you ask. A player needs no wallet, no gas and no account. The one-time deploy is the only thing anybody pays for.
Cartridge #0001
A game that is just JavaScript
The simple case. game.js is 14,147 bytes of source that becomes 6,056 bytes
through terser, 2,929 through gzip, and 2,942 on chain once the frame is wrapped around
it. Twelve percent of what a single contract can hold.
Its contract with the loader is deliberately thin: you will be evaluated in a blank document, and nothing will be injected for you. It creates its own canvas, its own stylesheet, its own audio context. Whatever is not in those 2,763 bytes does not exist, which is why it has no dependencies at all — not a framework, not a helper, not a font.
The game itself is a three-phase arcade loop: steal the gold, reach the trees, hand it out — ninety-nine levels of it.
Ninety-nine levels of one loop
Every dial that moves with the level is one pure function of it, and every dial has a ceiling. That second part is the whole difficulty of writing a hundred-level curve: the first version raised the guard count by one per level, which is fine at level five and asks for a hundred guards at level ninety-nine, at which point there is no game left.
| level | guard speed | coins | vault | wood | give | mercy |
|---|---|---|---|---|---|---|
| 1 | 1.20 | 3 | 2 | 3 | 11.9s | 124 |
| 20 | 1.59 | 5 | 3 | 5 | 10.7s | 105 |
| 40 | 2.00 | 7 | 5 | 7 | 9.3s | 85 |
| 60 | 2.40 | 8 | 7 | 9 | 8.0s | 65 |
| 99 | 3.20 | 8 | 9 | 11 | 5.4s | 45 |
One more thing the curve needs, which is not a number: a dozen guards that all home at exactly the same speed arrive as a single object, and a single object has no gaps in it. So the guards cycle through five slightly different paces by index. It costs no extra state per guard and it is the difference between a wall and a crowd.
Cartridge #0002
A real Atari 2600 ROM
This one is not JavaScript pretending to be retro. It is 4,096 bytes of 6502 machine code
written for the actual hardware, mapped at $F000–$FFFF with the
reset vector in the last four bytes. The same file runs in Stella. The same file would
run on a board.
What the machine gives you
A 2600 has 128 bytes of RAM. Not kilobytes — bytes. It has no framebuffer and no scanline buffer. The television is drawing whether you are ready or not, and the only way to put a picture on it is to write the video chip’s registers as the electron beam moves across the screen. This is called racing the beam, and it is not a metaphor: the CPU and the beam run off the same clock, three colour clocks per CPU cycle, 228 colour clocks per scanline, which is 76 CPU cycles a line.
Every frame is 262 scanlines of NTSC, and the program is responsible for all of them:
The kernel, line by line
The play area is a two-line kernel: each pass through the loop draws two scanlines, so 84 passes fill 168 lines and a sprite eight rows tall stands sixteen scanlines high. Two lines is 152 CPU cycles, which is the budget for everything — reading the sprite tables, deciding what the next line shows, and getting it written before the beam arrives.
The trick that makes it fit is writing the graphics register during horizontal blank, in
the first handful of cycles of a line, using a value computed on the previous
line. WSYNC is what buys that: writing to it parks the CPU until the beam
reaches the start of the next scanline, so the code after it always begins at a known
position.
.play
sta WSYNC ; park until the next line starts
lda NextGRP0 ; written inside horizontal blank, so it lands
sta GRP0 ; on this line before the beam reaches the sprite
lda NextGRP1
sta GRP1
lda NextENABL
sta ENABL
txa ; X counts down, so sprite tables read bottom-up
sec
sbc RobinY
cmp #SPRITE_H
bcs .noRobin
tay
lda RobinGfx,y ; this row of the sprite
jmp .setRobin
.noRobin
lda #0
.setRobin
sta NextGRP0 ; for the line after this one
Putting a sprite somewhere horizontally
There is no X coordinate register. A sprite’s horizontal position is set by
when you strobe RESP0 — the beam’s position at that instant
becomes the sprite’s position. So the standard routine turns a number into a delay:
sec
sta WSYNC
.div15
sbc #15 ; 2 cycles
bcs .div15 ; 3 cycles when taken — 5 in all
eor #7
asl
asl
asl
asl
sta HMP0,x ; the remainder, as fine motion
sta RESP0,x ; strobe: the sprite lands here
Collisions are hardware
Nothing in the game compares rectangles. The video chip latches, pixel by pixel, which
objects overlapped while it was drawing, and the program reads the result next frame:
bit 6 of CXP0FB is “the player touched the ball”, which in this
game means Robin picked up a coin. Bit 7 of CXPPMM is “the two players
touched”, which means the Sheriff caught him. One write to CXCLR
resets the latches for the next frame.
Ninety-nine levels
Difficulty is one 16-bit number: the Sheriff’s speed in 256ths of a step per frame. Level 1 is 82 of them, every level adds 6, and level 99 sits at 670 — 2.6 steps a frame against Robin’s 2. A fraction rather than an integer is what lets him be slower than one step a frame at the start and still move smoothly: the fraction piles up in an accumulator and whatever it carries out is a step.
| level | steps/frame | against Robin’s 2.0 | company |
|---|---|---|---|
| 1 | 0.32 | you walk away from him | one Sheriff |
| 20 | 0.75 | a real chase | one |
| 50 | 1.47 | he keeps up | two copies |
| 99 | 2.62 | faster than you can run | three copies |
Mercy shrinks alongside it — a hundred frames of invulnerability after a catch at level 1, fifty-one at level 99. The level itself is two playfield digits, and they get sixteen scanlines rather than eight because a block is four pixels wide while a scanline is half a pixel tall: at eight lines a digit reads as a stripe.
Why 4,096 bytes compress to 885
Because most of a 2600 cartridge is empty. The program and its tables come to about 1.2
KB; the remaining 2,909 bytes are padding out to the reset vector at
$FFFC, and a long run of zeroes is the easiest thing gzip will ever be
asked to do. The cartridge image has to be exactly 4,096 bytes because the 6502 addresses
it that way — but only the used part has any entropy in it.
The machine
The console is a contract too
A ROM needs something to run on. That something is vcs2600.js: a 6502, a TIA
and a RIOT in about 14 KB of minified JavaScript, deployed as its own contract at
0x672352e1…064a and read off the chain exactly like the game is.
It is a separate contract on purpose. A console is shared equipment — every 2600 cartridge anyone deploys can point at the same one, so the 1.2 million gas it costs is paid once per chain rather than once per game. It is also the larger of the two by nearly seven times, which makes that arithmetic matter.
What it models faithfully
- Cycle-counted 6502. Every documented opcode in all thirteen addressing modes, with the extra cycle on a page-crossing read and on a taken branch, and the indirect-jump page-wrap bug kept on purpose.
- A TIA ticked three colour clocks per CPU cycle, drawing as the beam moves rather than compositing a finished frame. 68 colour clocks of horizontal blank, then 160 visible pixels, 228 to the line.
- WSYNC parks the processor until the beam wraps, which is what lets a beam-racing kernel behave the way it does on hardware.
- Collision latches set from the pixels actually drawn, in the same priority order the chip uses — not from a geometric test done afterwards.
- A RIOT timer that keeps counting while the CPU is parked, because on the real chip it runs off the same clock and does not care that the processor is waiting.
- A television that locks to the sync pulse. The frame resets on the rising edge of VSYNC, not on a line count — get this wrong and the picture is stable but the bands land in the wrong place.
What it does not
Undocumented opcodes, HMOVE comb artefacts, player start-up delays, and the TIA’s
audio polynomial counters — sound is approximated with two oscillators rather than
synthesised properly. A cartridge that leans on any of those will look or sound wrong
here. Ours does not lean on them, and the test suite is what says so rather than the
author’s word: the ROM is driven headless through SWCHA and the
assertions read the 128 bytes of RAM the program actually wrote.
A frame measures 59,604 colour clocks rather than the 59,736 that 262 lines would give. The difference is 132 clocks, a little over half a line, and it is correct: the ROM asserts VSYNC a few cycles into a line rather than exactly at its start, so the television latches slightly early. Real cartridges do the same thing.
Composition
One address, two contracts
A player should not have to know that a 2600 game is two contracts, or which is which, or
which order to fetch them in. So the ROM carries the answer: the twenty bytes of
ext are the address of the console it wants.
0xdc28337a…20f6 796 bytes HOODCART v2 · kind 1 · atari 2600 rom
ext → 0x672352e18a182a46095bd49b214b0c3bcd36064a
│
▼
0x672352e1…064a 5,372 bytes HOODCART v2 · kind 2 · javascript console
This has a practical consequence for deployment: the console has to exist before the ROM can be built, because the ROM’s bytes contain the console’s address. Deploy order is not a preference, it is a dependency — and it is why the browser deployer assembles init code in the browser rather than shipping a pre-baked blob.
A third hop, for tokens
The loader will also start from an address that merely points at a cartridge, so
a launch can hand out one contract address and have the game come up from it. When the
code at an address is not a cartridge, the page asks it two questions, in order, both
plain eth_call:
| call | returns | strength of the binding |
|---|---|---|
| cartridge() | an address | strong — make the field immutable and nobody can ever repoint it |
| description() | a string with an address in it | weak — usually editable by whoever controls the token |
The second exists because launchpads deploy their own token contracts, so you cannot add a function to them; a description field is the only place to put anything. It works, and it is worth knowing which of the two you are relying on: the first says where the game is forever, the second says where it is today.
Runtime
Booting, step by step
This is the whole sequence for the Atari cartridge, from a pasted address to a picture. Every network call is listed; there are no others.
- 01
eth_chainIdandeth_blockNumberso the page can say which chain it read, and at what height - 02
eth_getCodeon the address you gave it 796 bytes come back - 03
Check byte 0 for
STOP, bytes 1–8 for the magic, then read version, flags, kind and extension length anything that fails here stops the boot with a sentence, not a blank screen - 04
Kind is 1, so read the twenty extension bytes as the console’s address, and call
eth_getCodeon that too 5,372 more bytes - 05
Inflate both payloads with the browser’s own
DecompressionStream763 → 4,096 bytes of cartridge image; 5,359 → 14,442 bytes of JavaScript - 06
Hash the inflated ROM with
crypto.subtleand show the digest so what happens next is checkable against something you can reproduce - 07
Build a document: a prelude that puts the ROM bytes in
window.HOODROM, followed by the console’s JavaScript every “<” is escaped so nothing in the payload can close the script tag it rides in - 08
Hand that document to a sandboxed
iframe, which starts the 6502 at the address in the ROM’s reset vector the console posts back once it has painted a frame
Two round trips to a public RPC, and on mainnet that has measured around 12.6 seconds
end to end — nearly all of it waiting for the two eth_getCode calls to a
public endpoint. Against a node on the same machine the same boot takes 33 milliseconds.
The work after the bytes arrive is not the slow part.
Safety
Why the game runs in a box
The loader executes code fetched from an address a stranger typed into a text field. That is the entire point of the project and also its sharpest edge, so the payload never runs in the page.
It runs in an iframe with sandbox="allow-scripts" and
without allow-same-origin. The omission is the security
property: the frame gets an opaque origin, which means the code inside cannot reach the
loader’s DOM, cannot read its storage, cannot see an injected wallet, and cannot
touch anything else the viewer has open. It can draw on its own canvas, take its own
keyboard input, and make its own noise.
This matters more for a cartridge than for the audio work the same repository does with contract bytecode. Audio is data you decode. A cartridge is a program you run.
It does not make an unknown cartridge trustworthy. Code from an address you have no reason to trust is still code from an address you have no reason to trust — the sandbox bounds the damage, it does not vet the author. The loader says as much on the page rather than implying otherwise.
Trust
Checking it yourself
A page that claims to read a chain cannot prove that claim. It could carry the game inside it and fake the call, and no amount of convincing text on the page would settle the question — the page is exactly the thing in doubt.
So the loader does not try to be believed. It publishes what a sceptic needs to check it without using the page at all:
- The contract address, linked to a block explorer. Blockscout will show you the bytecode, the creation transaction, the deployer and the block. None of that comes from us.
- An RPC field you can overwrite. Point the page at a node you run and it reads from there. It is deliberately not settable from the URL, so a crafted link cannot quietly redirect it somewhere helpful.
- The block it read at, so “live” is a number rather than an adjective.
- A command that reproduces the whole thing — fetch, unwrap, inflate, hash — in one line of node with no dependencies. Run it, compare the digest to the one on screen.
node -e "fetch('https://rpc.mainnet.chain.robinhood.com',{method:'POST',
headers:{'content-type':'application/json'},
body:JSON.stringify({jsonrpc:'2.0',id:1,method:'eth_getCode',
params:['0xdc28337aab8d31ddd63f8b817fad51c4ee9620f6','latest']})})
.then(r=>r.json()).then(j=>{
const b=Buffer.from(j.result.slice(2),'hex');
const p=require('zlib').gunzipSync(b.subarray(33));
console.log(b.length+' bytes of contract code, '+p.length+' bytes inflated');
console.log(require('crypto').createHash('sha256').update(p).digest('hex'))})"
The expected answer for the cartridge above is 796 bytes in, 4,096 out, and the digest 240f79ef…2ac570. The test suite runs that command as a test and fails if the page ever hands out one that does not reproduce what it printed.
Publishing
Putting one on chain
There are two ways, and they differ mainly in where the private key is.
From a wallet
A browser page assembles the init code and asks the wallet to sign one transaction per cartridge. No key in a file, no key in a terminal, no key in the page — the wallet signs and the page never sees one. It carries the compressed payloads and 37 KB of its own code, with no ethers and no CDN, because a deploy here is raw calldata and there is nothing to ABI-encode.
The assembly has to happen in the browser rather than at build time. The ROM’s header holds the console’s address, and on a fresh chain that address does not exist until the wallet has created it seconds earlier. A pre-baked init code could not contain it.
After each transaction the page reads the new contract back with eth_getCode
and compares its hash against what it signed. A step is not called done until that
matches: a contract you cannot read back is not a cartridge.
From a terminal
The same deploy exists as a script, for scripting and for local chains. It refuses to run on any public network if the key it finds is one of the five that anvil and hardhat print in their own startup banners. Those keys are public; the contracts they create are fine, since code is immutable, but the address is not yours and anything sent there to cover gas is swept within seconds.
Workshop
Two tools we had to write
A 6502 assembler
dasm is what every 2600 source in the world is written for, and it is a binary you have
to go and install. So the project carries its own, speaking the same dialect: labels
including dasm’s local-label scoping, the documented opcode set in all thirteen
addressing modes, seg / seg.u, org,
dc.b, ds, equ, align, and expressions
with dasm’s < and > byte selectors.
It runs passes until every address stops moving. That is not fussiness: the first pass does not know yet which symbols live in zero page, so it encodes them long, and everything after them shifts when they shorten. An early version reported branch distances from a pass where addresses had not settled and rejected perfectly good code — problems are now collected and only reported from the pass where nothing moved.
A PNG writer
For looking at what the beam drew without a graphics library. Node already has the hard part — PNG’s image data is a zlib stream — so what is left is three chunks and a CRC. It exists because a ROM you cannot see is a ROM you cannot debug.
Edges
Where this stops working
EIP-170 caps a contract’s code at 24,576 bytes. That is the ceiling on a single cartridge, and in practice it means roughly 24 KB of gzip — call it 60 to 80 KB of minified JavaScript, depending on how repetitive it is.
That is a lot for a hand-written game and nowhere near enough for an engine. The console, the largest thing here, uses 21.9% of it. Past the cap a payload has to span several contracts and be stitched back together on read, which is what the audio side of this same repository already does — the loader would need to learn to read a list of addresses instead of one.
Two smaller limits are worth naming. Blob data is not an option: EIP-4844 blobs expire in about eighteen days, and the whole premise here is permanence. And a deployed cartridge cannot be edited or removed by anyone, including whoever deployed it — which is the point, and also the reason to be certain about what you are publishing before you sign.
The cartridge at 0xdc28337a…20f6 is the build before the ninety-nine-level
curve: 796 bytes, ten levels, one Sheriff. Nothing can change that — a new version is a
new contract at a new address, and the old one keeps working for as long as the chain
does. The console is untouched by any of it, so the next cartridge points at the same
0x672352e1…064a and only pays for itself.