Skip to content

Writing a ROM

GattoDev edited this page Jul 26, 2026 · 6 revisions

Writing a ROM

A ROM is just a GDScript file. It's your program — the thing that actually runs on the fake hardware. The CPU loads it, wires up references to everything, and calls your tick function every frame.


Swapping the active ROM

Only one ROM can run at a time. The project uses the custom "Rom File" field in the CPU node to identify which file is the active cartridge.

To switch between them, Click on the CPU node in the tree, and drag your file onto the "Rom File" field. Screenshot_2026-07-21_21-00-33


The minimum viable ROM

extends Node
class_name ROM

var ppu: PPU
var apu: APU
var cpu: CPU

func _enter_tree() -> void:
    name = "ROM"

func tick(delta: float):
    ppu.clear(Color.BLACK)
    ppu.text(8, 8, "HELLO WORLD", Color.WHITE)

That's a complete, working ROM. It clears the screen every frame and draws text.


What each part does

extends Node — ROMs are Godot nodes, so they need this.

var ppu, var apu, var cpu — declare these and the CPU auto-fills them before anything runs. You don't need all three — only declare the ones your ROM actually uses. If the variable isn't there, the CPU skips it.

_enter_tree() setting name = "ROM" — important. The CPU finds your ROM in the scene tree by looking for a node named "ROM". If you skip this, it won't connect properly.

tick(delta: float) — called every frame while the CPU is running. delta is the time since the last frame in seconds, same as Godot's _process. All your game logic goes here.


Common patterns

Animating with time

Accumulate delta into a variable and use it to drive animations:

var t := 0.0

func tick(delta: float):
    t += delta
    ppu.clear(Color.BLACK)
    var x = int(128 + sin(t) * 60)
    ppu.circlefill(x, 128, 20, Color.WHITE)

Playing audio

Enable channels in _enter_tree, then update them in tick:

func _enter_tree() -> void:
    name = "ROM"
    apu.pulse1_enabled = true

func tick(delta: float):
    t += delta
    apu.pulse1_freq = 220 + sin(t * 2.0) * 100

Triggering a crash

if Input.is_action_just_pressed("ui_cancel"):
    cpu.panic("USER QUIT", "Escape key was pressed")

Things that will catch you out

Forgetting ppu.clear() — if you don't clear the screen at the start of tick, every frame draws on top of the last one. Shapes will smear across the screen. Sometimes that's intentional, usually it isn't.

cpu.panic() is permanent — once called, the CPU stops forever. The only way back is restarting the project. Don't call it for recoverable errors.

Text is always uppercaseppu.text() calls .to_upper() on whatever string you pass in. Lowercase works fine as input, it just renders as uppercase.

ppu.render() does nothing — it exists for compatibility but has no implementation. Don't rely on it.

noise_enabled isn't a register — you toggle it directly: apu.noise_enabled = true. The register map doesn't include it.


Learning from the demo

Software/ROM.gd is a good thing to read through. It shows:

  • How to use t for animation (sin, cos to drive positions and frequencies)
  • How to draw multiple things per frame (clear once, then draw everything)
  • How to rotate geometry by hand (transform points with Vector2.rotated, then draw lines between them)
  • How to make the noise channel fire in bursts (apu.noise_enabled = (sin(t * 12.0) > 0.92))
  • How to display live debug info with ppu.text

Software/Template.gd is a blank slate — just the font test, easy to delete and replace with your own stuff.

Clone this wiki locally