RROBINARCADE LIVE Publish a game
How it works

Cartridge Internals

Two games live in the runtime bytecode of contracts on Robinhood Chain. One is JavaScript. The other is a real 4 KB Atari 2600 ROM, and the 6502 that runs it is a contract as well. No IPFS, no CDN, no game files anywhere — press play and the bytes come off the chain.

Cartridge #0002 · mainnet · v1
0xdc28337aab8d31ddd63f8b817fad51c4ee9620f6
Its console · mainnet
0x672352e18a182a46095bd49b214b0c3bcd36064a
The ROM, on chain
796 bytes
Cost to play
nothing

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:

stop 00
magic — “HOODCART” 484f4f 444341 5254
ver 02
flags 01
kind 01
ext len 14
ext — the console’s address, 20 bytes 672352 e18a
payload — 763 bytes of gzip 1f8b08
796 bytes in total. 14 is twenty in hex, and 1f 8b is gzip’s own magic number, so the payload announces itself too.

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:

kindpayloadwhat the loader does
0JavaScriptruns it in a blank sandboxed document
1an Atari 2600 ROMfetches the console named in ext, then runs the ROM on it
2a JavaScript consolerefuses — 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.

One difference worth knowing

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:

byteopmnemonicstack afterwards
060 0bPUSH1 1111
259MSIZE11, 0
381DUP211, 0, 11
438CODESIZE11, 0, 11, size
503SUB11, 0, len
680DUP111, 0, len, len
792SWAP3len, 0, len, 11
859MSIZElen, 0, len, 11, 0
939CODECOPYlen, 0
10f3RETURN
MSIZE is zero at the start of execution, which is one byte cheaper than PUSH1 0 and the reason it appears twice. CODESIZE minus 11 is the payload length, whatever the payload turns out to be — the same eleven bytes work for a 796-byte cartridge and a 5,372-byte console.

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
807 bytes of calldata, one transaction, no contract source anywhere.

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,4425,3595,372 1,225,5090.0000821
#0002 ROM 4,096885918 251,4040.0000166
#0001 game 6,0562,9292,942 689,2220.0000461
Bytes, and gas from eth_estimateGas against Robinhood Chain mainnet at 0.066 gwei. Source column is the packed payload: minified JavaScript, or the raw cartridge image for the ROM. Total for all three: 2,110,575 gas, about 0.00014 ETH.

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.

levelguard speedcoins vaultwoodgivemercy
11.2032311.9s124
201.5953510.7s105
402.007579.3s85
602.408798.0s65
993.2089115.4s45
Speed in pixels a frame, against the player's 2.5 — so the guards start comfortably slower and end faster than anyone can run. Mercy is the frames of invulnerability after a catch. Read out of the shipped cartridge by the test, not from a copy.

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:

vertical sync3 lines
vertical blank — all the thinking happens here37
level counter — two digits, two scanlines a row16
play area — 84 iterations of a two-line kernel168
status band — lives and gold8
overscan30
3 + 37 + 16 + 168 + 8 + 30 = 262. Miss the count and the picture rolls — and the kernel has to spend exactly 192 WSYNCs, because a WSYNC is a scanline. An earlier version kept a spare one between each band, ran 194, and every frame came out cut in half. The RIOT chip’s timer keeps the blank stretches honest: write 43 to TIM64T and it counts down 43 × 64 CPU cycles, then the kernel starts at zero.

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
Twenty-three cycles for one sprite, about sixty-four for the whole first line of the pair. Twelve to spare out of seventy-six.

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
Five CPU cycles is fifteen colour clocks is fifteen pixels, so each turn of the loop walks the beam exactly one coarse block. The leftover goes into the fine-motion register and HMOVE applies it later. A division loop, used as a ruler.

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.

levelsteps/frameagainst Robin’s 2.0company
10.32you walk away from himone Sheriff
200.75a real chaseone
501.47he keeps uptwo copies
992.62faster than you can runthree copies
Measured by pinning Robin in a corner and counting how far the Sheriff travels in thirty frames. The copies are NUSIZ: one register makes the TIA draw the same player two and three times over, thirty-two pixels apart.

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 number that looks wrong and is not

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.

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.

  1. 01 eth_chainId and eth_blockNumber so the page can say which chain it read, and at what height
  2. 02 eth_getCode on the address you gave it 796 bytes come back
  3. 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
  4. 04 Kind is 1, so read the twenty extension bytes as the console’s address, and call eth_getCode on that too 5,372 more bytes
  5. 05 Inflate both payloads with the browser’s own DecompressionStream 763 → 4,096 bytes of cartridge image; 5,359 → 14,442 bytes of JavaScript
  6. 06 Hash the inflated ROM with crypto.subtle and show the digest so what happens next is checkable against something you can reproduce
  7. 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
  8. 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.

What the sandbox does not do

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'))})"
33 is where the payload starts for a kind-1 cartridge: one STOP, eight magic, four header, twenty of console address. For kinds 0 and 2 there is no extension and it is 13. The page works this out and writes the right number into the command.

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.

Which is why versions are addresses

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.