1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 17:06:04 +00:00
nescript/docs/language-guide.md
Claude d42540f45e
audio: complete the subsystem — asset pipeline, user decls, tracker-style driver
The audio subsystem was a sketch: `play name` / `start_music name` /
`stop_music` parsed, lowered, and emitted a few hardcoded register
writes from a builtin name table. No user-declared effects, no
per-frame envelope, no note streams, no real engine.

This flesh-out brings audio up to the quality bar of the rest of
the compiler (sprites, palettes, bank switching, scanline IRQ,
etc.) with a full data-driven pipeline:

## Asset pipeline (new `src/assets/audio.rs`)

- `sfx Name { duty, pitch, volume }` blocks compile into per-frame
  pulse-1 envelopes. Pitch/volume arrays must match in length; each
  entry is one NMI's worth of `$4000` data.
- `music Name { duty, volume, repeat, notes }` blocks compile into
  flat `(pitch, duration)` streams for pulse 2. Pitch 0 is a rest,
  1-60 indexes a builtin period table covering C1-B5.
- `resolve_sfx` / `resolve_music` walk the program for `play` /
  `start_music` references and append builtin fallbacks for any
  name that isn't user-declared — so `play coin` still works
  without a `sfx Coin { ... }` block.
- Builtin effects (coin, jump, hit, click, cancel, shoot, step)
  and tracks (theme, battle, victory, gameover) synthesize through
  the same compile path as user decls — one data model, one driver.

## Runtime engine (`src/runtime/mod.rs`)

- `gen_audio_tick()` walks both channels every NMI: reads one
  envelope byte through `(ZP_SFX_PTR),Y` -> writes `$4000`,
  advances ptr, mutes on zero sentinel. Music decrements the note
  counter, advances to the next `(pitch, dur)` pair on zero, looks
  up the period through `(__period_table),Y`, loops on `0xFF 0xFF`.
- `gen_period_table()` emits a 60-entry equal-tempered table
  (A4 = 440 Hz, NTSC 1.789773 MHz CPU clock) with length-counter
  load bits pre-baked into each high byte.
- `gen_data_block()` emits a label + raw-bytes pseudo pair so
  user sfx/music data can be spliced into PRG with regular labels
  that the two-pass assembler resolves.
- New ZP layout: `$05/$06` music loop base, `$07` music state
  (duty/volume/loop/active), `$0C-$0F` sfx and music pointers.

## IR codegen (`src/codegen/ir_codegen.rs`)

- `with_audio(sfx, music)` registers compile-time trigger constants
  per blob name.
- `gen_play_sfx` emits: write period to `$4002`/`$4003`, load
  envelope pointer into `ZP_SFX_PTR` via SymbolLo/SymbolHi of
  `__sfx_<name>`, mark the sfx counter active.
- `gen_start_music` stamps the header byte into `ZP_MUSIC_STATE`
  with the active bit OR'd in, seeds both ptr and loop base from
  `__music_<name>`, primes the duration counter.
- `gen_stop_music` mutes pulse 2 and clears state.

## Linker (`src/linker/mod.rs`)

- New `link_with_all_assets(user_code, sprites, sfx, music)` path
  that splices driver body, period table, and each sfx/music data
  blob into PRG — all guarded on the `__audio_used` marker so
  silent programs pay zero ROM cost.

## Assembler (`src/asm/opcodes.rs`, `src/asm/mod.rs`)

- New `AddressingMode::Bytes(Vec<u8>)` variant for raw-data
  pseudo-instructions. `NOP+Bytes(v)` emits the payload verbatim,
  letting the linker splice ROM data tables into a code section
  and still have `Label` / `SymbolLo` / `SymbolHi` fixups resolve
  correctly in the same assembly pass.

## Analyzer

- `play` / `start_music` now validate the name against user decls
  and builtin tables. Unknown names emit E0505 with a helpful list
  of builtins — previously a typo would silently compile to no-op.

## Parser

- New `sfx_decl` / `music_decl` grammar with property-style
  configuration. Strict validation: duty 0-3, volume 0-15, pitch
  arrays must match volume length, music notes must come in pairs,
  pitch 0-60, duration ≥ 1.

## Tests

+170 new tests across every layer:
- `src/assets/audio.rs`: 17 tests (compile, resolve, builtins,
  shadowing, label sanitation, nested reference walks)
- `src/parser/tests.rs`: 13 tests (valid/invalid sfx + music
  declarations, property validation, play/start_music/stop_music)
- `src/analyzer/tests.rs`: 7 tests (builtin acceptance, user decl
  acceptance, unknown-name rejection)
- `src/runtime/tests.rs`: 10 tests (audio tick labels, RTS end,
  $4000 write, $4004 mute, period table assembly, A4 = 440 Hz,
  length counter bits, data block verbatim emit)
- `src/linker/tests.rs`: 4 tests (sfx/music blob placement,
  pointer resolution, elision when unused)
- `src/codegen/ir_codegen.rs`: rewrote the 4 existing audio tests
  to match the new data-driven contract
- `tests/integration_test.rs`: 4 end-to-end tests including a
  user-declared `sfx` + `music` program that verifies bytes land
  in PRG ROM at the right addresses

## Docs

- New Audio section in `docs/language-guide.md` with syntax
  reference, builtin tables, and an explanation of how the
  driver works at compile and run time.
- `docs/architecture.md` updated to reflect the real audio
  pipeline instead of the old "audio import stubs" stub.
- `docs/future-work.md` moves audio from "status: minimal" to
  "status: full subsystem" with a narrower list of follow-up work
  (triangle/noise/DMC channels, NSF/FTM imports, richer envelopes).
- `examples/audio_demo.ne` rewritten to showcase user-declared
  `sfx LongCoin`, `sfx Zap`, `music Theme`, still demonstrating
  builtin fallback via `play coin`.

Total: 424 tests passing (381 unit + 43 integration), clippy clean,
fmt clean, all 19 examples compile.

https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
2026-04-13 01:10:21 +00:00

28 KiB

NEScript Language Guide

NEScript is a statically-typed, compiled language designed for NES game development. It compiles directly to 6502 machine code packaged as iNES-format ROMs -- no external assembler or tooling required.

This guide covers every language feature with practical examples.


Program Structure

Every NEScript program consists of a game declaration, top-level definitions, and a start declaration.

game "My Game" {
    mapper: NROM
    mirroring: vertical
}

const SPEED: u8 = 2

var score: u8 = 0

fun helper() -> u8 {
    return 42
}

state Title {
    on frame {
        draw Logo at: (100, 100)
        if button.start {
            transition Playing
        }
    }
}

state Playing {
    on enter {
        score = 0
    }
    on frame {
        // game logic here
    }
}

start Title

Game Declaration

The game block is required and must appear first. It names the game and sets hardware configuration.

game "Coin Cavern" {
    mapper: NROM
    mirroring: vertical
}

Available properties:

Property Values Default
mapper NROM, MMC1, UxROM, MMC3 required
mirroring horizontal, vertical horizontal

Start Declaration

Exactly one start declaration must exist. It names the initial state entered on power-on.

start Title

Types

NEScript has four primitive types and fixed-size arrays.

Primitive Types

Type Size Range Description
u8 1 byte 0 to 255 Unsigned 8-bit integer
i8 1 byte -128 to 127 Signed 8-bit integer
u16 2 bytes 0 to 65535 Unsigned 16-bit integer
bool 1 byte true / false Boolean

Arrays

Arrays are fixed-size, homogeneous, and zero-indexed. The size must be a compile-time constant. Maximum 256 elements.

var enemies: u8[8]
const TABLE: u8[4] = [10, 20, 30, 40]

Type Casting

NEScript has no implicit coercion. All conversions use as:

var a: u8 = 200
var b: u16 = a as u16       // zero-extend: 200
var c: i8 = a as i8         // reinterpret bits
var d: u8 = b as u8         // truncate to low byte

Variables

Variable Declarations

Variables are declared with var and must have an explicit type:

var x: u8                   // uninitialized (zeroed on state entry)
var y: u8 = 100             // initialized
var pos: u16 = 0x0400       // 16-bit value
var alive: bool = true
var scores: u8[4] = [0, 0, 0, 0]

Constants

Constants are evaluated at compile time and stored in ROM:

const MAX_ENEMIES: u8 = 5
const SPEED: u8 = 3
const SIN_TABLE: u8[8] = [0, 49, 90, 117, 127, 117, 90, 49]

Enums

Enums declare a named set of u8 constants. Each variant is assigned an index starting at 0 in declaration order:

enum Direction { Up, Down, Left, Right }
// Up=0, Down=1, Left=2, Right=3

var player_dir: u8 = Up

on frame {
    if button.left  { player_dir = Left }
    if button.right { player_dir = Right }
    if player_dir == Down { /* ... */ }
}

Variant names are global — they are flattened into the top-level symbol table, so a variant cannot share its name with any other constant, variable, or function (E0501). An enum cannot have more than 256 variants because each is stored as a u8.

Structs

Structs declare composite types with named fields:

struct Vec2 {
    x: u8,
    y: u8,
}

struct Player {
    health: u8,
    lives: u8,
}

var pos: Vec2
var hero: Player

on frame {
    pos.x = 100
    pos.y = 50
    hero.health = 3
    hero.lives = 5
    if button.right { pos.x += 1 }
    draw Hero at: (pos.x, pos.y)
}

Fields are laid out contiguously in declaration order. A variable of struct type allocates enough contiguous bytes to hold all its fields; each field is accessible via the dot operator.

Struct literals initialize or assign all fields at once:

struct Vec2 { x: u8, y: u8 }

// as an initializer
var pos: Vec2 = Vec2 { x: 100, y: 50 }

// as an assignment
on frame {
    pos = Vec2 { x: 0, y: 0 }
    if button.right {
        pos = Vec2 { x: pos.x + 1, y: pos.y }
    }
}

Inside if, while, and for conditions the struct literal syntax is reserved for the following block, so wrap the literal in parens if you ever need one in a condition:

if pos == (Vec2 { x: 0, y: 0 }) { /* ... */ }

In v0.1 only primitive field types (u8, i8, bool) are supported — nested structs, u16, and array fields are not yet allowed.

Memory Placement Hints

The NES has 256 bytes of zero-page RAM with faster access. You can hint where variables should be placed:

fast var px: u8             // prefer zero-page (faster instructions)
slow var high_score: u16    // prefer upper RAM (saves zero-page space)
var normal: u8              // compiler decides automatically

If zero-page is exhausted and fast variables cannot be placed, the compiler emits error E0301.

Scope

Scope Declared In Lifetime
Global Top level Entire program, permanent RAM allocation
State state block Active while state is active; RAM reusable
Function fun block Duration of function call
Block if/while Enclosing block, shares parent allocation

Functions

Declaration

Functions use fun, with optional parameters and return type:

fun add(a: u8, b: u8) -> u8 {
    return a + b
}

fun reset_score() {
    score = 0
}

Inline Functions

The inline keyword hints the compiler to inline the function at call sites:

inline fun clamp(val: u8, max: u8) -> u8 {
    if val > max {
        return max
    }
    return val
}

inline is a hint -- the compiler may decline for large functions.

Calling Functions

var result: u8 = add(10, 20)
reset_score()

Restrictions

  • No recursion. Both direct and indirect recursion are compile errors (E0402).
  • Call depth limit. The default maximum call depth is 8. Exceeding it produces error E0401.

States

States are the top-level organizational unit. Exactly one state is active at any time.

State Declaration

state Playing {
    var timer: u8 = 0           // state-local variable

    on enter {
        // runs once when entering this state
        timer = 60
    }

    on exit {
        // runs once when leaving this state
    }

    on frame {
        // runs every frame (60 Hz) while this state is active
        timer -= 1
        draw Player at: (player_x, player_y)
    }
}

on frame is syntactic sugar for a loop with an implicit wait_frame() at the end. A state can have any combination of on enter, on exit, and on frame.

State Transitions

transition GameOver

Transitions are immediate. The current state's on exit runs, then the target state's on enter runs. The remainder of the current frame handler does not execute.


Expressions

Literals

42              // decimal integer
0xFF            // hexadecimal
0b10110001      // binary
1_000           // underscores allowed for readability (if supported)
true            // boolean
false           // boolean
[1, 2, 3]      // array literal

All integer literals must fit in u16 (0-65535). The compiler narrows to the required type at usage.

Arithmetic Operators

Operator Description Example
+ Addition a + b
- Subtraction a - b
* Multiplication a * b
/ Division a / b
% Modulo a % b

*, /, and % are available but expensive on the 6502 (software routines). The compiler optimizes power-of-two operations to shifts and warns on non-power-of-two multiply/divide.

Bitwise Operators

Operator Description Example
& Bitwise AND a & 0x0F
| Bitwise OR a | 0x80
^ Bitwise XOR a ^ mask
~ Bitwise NOT ~a
<< Shift left a << 2
>> Shift right a >> 1

Comparison Operators

Operator Description Example
== Equal a == 0
!= Not equal a != b
< Less than a < 10
> Greater than a > max
<= Less or equal a <= 255
>= Greater or equal a >= min

Logical Operators

NEScript uses keyword-based logical operators:

if alive and (health > 0) {
    // ...
}
if not paused or force_update {
    // ...
}
Operator Description
and Logical AND
or Logical OR
not Logical NOT

Operator Precedence

From highest to lowest:

Level Operators Associativity
1 () grouping --
2 - (unary), ~, not right
3 *, /, % left
4 +, - left
5 <<, >> left
6 & left
7 ^ left
8 | left
9 ==, !=, <, >, <=, >= left
10 and left
11 or left

Button Reads

Read controller input as boolean expressions:

if button.right {
    player_x += SPEED
}
if button.a {
    jump()
}

Available buttons: up, down, left, right, a, b, start, select.

For two-player games, prefix with the player:

if p1.button.a { /* player 1 */ }
if p2.button.right { /* player 2 */ }

Without a prefix, button refers to player 1.

Function Calls in Expressions

var clamped: u8 = clamp_x(player_x + SPEED)

Array Indexing

var val: u8 = table[i]
table[i] = 0

Type Casting

var wide: u16 = narrow as u16

Statements

Assignment

x = 10
x += 5
x -= 1
x &= 0x0F
x |= 0x80
x ^= mask

All assignment operators:

Operator Description
= Assign
+= Add and assign
-= Subtract and assign
&= AND and assign
|= OR and assign
^= XOR and assign

Array element assignment:

enemies[i] = 0
scores[player] += 10

If / Else If / Else

Braces are always required. No ternary operator.

if health == 0 {
    transition GameOver
} else if health < 3 {
    flash_warning()
} else {
    // normal gameplay
}

While Loop

var i: u8 = 0
while i < 10 {
    enemies[i] = 0
    i += 1
}

Match Statement

match matches a scrutinee against a sequence of patterns and executes the body of the first matching arm. Each arm's pattern is compared against the scrutinee with ==. An underscore arm _ acts as the catch-all:

enum State { Title, Playing, GameOver }
var state: u8 = Title

on frame {
    match state {
        Title => {
            if button.start { state = Playing }
        }
        Playing => {
            // ... game logic ...
        }
        GameOver => {
            if button.a { state = Title }
        }
        _ => {}
    }
}

match desugars to an if / else if chain at parse time, so patterns can be any expression that produces a value comparable to the scrutinee.

For Loop

The for loop iterates over a half-open integer range [start, end):

for i in 0..8 {
    total += arr[i]
}

The loop variable is a u8 scoped to the loop body. Both bounds can be any expression that evaluates to u8 at runtime, including constants or variables. The range is half-open, so 0..8 iterates 0, 1, 2, ..., 7 (8 iterations). For a closed range, use 0..9.

The loop is desugared into a while loop with an index variable, so break and continue work the same as in any loop body.

Loop (Infinite)

loop {
    wait_frame()
    if button.start {
        break
    }
}

The compiler warns if a loop contains neither break, wait_frame, nor transition.

Break and Continue

var i: u8 = 0
while i < 20 {
    i += 1
    if enemies[i] == 0 {
        continue            // skip inactive enemies
    }
    if i > 10 {
        break               // stop processing
    }
    update_enemy(i)
}

Return

fun abs_diff(a: u8, b: u8) -> u8 {
    if a > b {
        return a - b
    }
    return b - a
}

Functions without a return type use return with no value (or simply reach the end of the function body).

Draw

Render a sprite to the screen:

draw Player at: (player_x, player_y)
draw Coin at: (COIN_X, COIN_Y) frame: anim_frame

The draw statement writes to the OAM shadow buffer. The NES supports up to 64 sprites per frame.

Syntax: draw SpriteName at: (x_expr, y_expr) [frame: expr]

Transition

Switch to another state immediately:

transition GameOver

The current state's on exit runs, then the target state's on enter runs.

Wait Frame

Yield execution until the next vertical blank (NMI). Synchronizes to the 60 Hz display refresh.

wait_frame()

This triggers OAM DMA transfer and PPU updates before yielding. Inside on frame, a wait_frame() is implicit at the end of each frame.

Scroll

Set the PPU scroll position:

scroll(scroll_x, scroll_y)

Load Background

Load a background nametable:

load_background TitleBG

Set Palette

Apply a palette:

set_palette GamePalette

Function Calls as Statements

reset_score()
update_physics(player_x, player_y)

Assets

Sprite Declarations

sprite Player {
    chr: @chr("assets/player.png")
}

sprite Coin {
    chr: @binary("assets/coin.bin")
}

Palette Declarations

Define color palettes using NES PPU color indices ($00-$3F):

palette GamePalette {
    colors: [0x0F, 0x00, 0x10, 0x30,
             0x0F, 0x07, 0x17, 0x27,
             0x0F, 0x09, 0x19, 0x29,
             0x0F, 0x01, 0x11, 0x21]
}

Each group of 4 values is a sub-palette. The first color is typically 0x0F (black) as the shared background color.

Background Declarations

background TitleBG {
    chr: @chr("assets/title_screen.png")
}

Asset Sources

Three ways to provide asset data:

Source Description
@chr("file.png") Convert PNG to CHR tile data
@binary("file.bin") Include raw binary data verbatim
Inline [0x00, 0x7E, ...] Hex byte array directly in source

Audio

NEScript ships with a full data-driven audio subsystem. Sound effects run on pulse channel 1 and music runs on pulse channel 2, both driven by an NMI-time tick that walks per-track data tables compiled into PRG ROM. Programs that never touch audio pay zero ROM or cycle cost — the driver and its period table are only linked in when user code contains at least one play, start_music, or stop_music statement.

Statements

play SfxName          // trigger a one-shot sound effect
start_music TrackName // begin looping background music
stop_music            // silence the music channel

Each statement looks up the name in the program's user declarations first, then falls back to the builtin table. Unknown names are a hard error (E0505).

SFX Declarations

An sfx block is a frame-accurate envelope for pulse 1. pitch latches the pulse period on trigger; volume runs one entry per frame, so the envelope length controls the effect duration.

sfx Pickup {
    duty: 2                                   // 0-3, 2 = 50% square (default)
    pitch: [0x50, 0x50, 0x50, 0x50, 0x50]     // period for each frame
    volume: [15, 12, 9, 6, 3]                  // 0-15, one per frame
}

Rules:

  • pitch and volume must have the same length (the frame count).
  • volume values are 0-15 (4-bit pulse volume).
  • duty is 0-3 and defaults to 2.
  • Maximum 120 frames (2 seconds at 60 fps).

Music Declarations

A music block is a flat list of (pitch, duration) note pairs played on pulse 2. Pitch 0 is a rest; pitches 1-60 are indices into the builtin 60-note period table (C1 through B5, with middle C at index 37). Duration is in frames (so at 60 fps, 30 is half a second).

music Theme {
    duty: 2                                   // 0-3 (default 2)
    volume: 10                                // 0-15 (default 10)
    repeat: true                              // loop when track ends (default true)
    notes: [
        37, 20,    // C4 for 20 frames
        41, 20,    // E4
        44, 20,    // G4
        49, 20,    // C5
        0, 10,     // rest for 10 frames
    ]
}

Rules:

  • notes must contain an even number of bytes (pitch + duration pairs).
  • Pitches are 0 (rest) or 1-60 (period table index).
  • Duration must be ≥ 1 frame.
  • Maximum 256 notes per track.

Builtin Names

For programs that want classic game audio without writing data tables, NEScript provides a handful of builtin effects and tracks that can be used directly:

Builtin SFX

Name Description
coin, pickup, collect Ascending high blip
jump, hop Descending arc
hit, damage, explode Low blast
click, select, confirm Sharp beep
cancel, back, error Low longer tone
shoot, laser, fire Very high pulse
step, footstep Short low thud

Builtin Music

Name Description
title, theme, main Major arpeggio (looping)
battle, boss Driving pulse (looping)
win, victory, fanfare Ascending burst (one-shot)
gameover, lose, fail Descending dirge (looping)

A user-declared sfx or music block takes priority over a builtin with the same name, so sfx coin { ... } will shadow the default coin effect.

How It Works

Compile time:

  1. The resolver compiles each sfx into (period_lo, period_hi, envelope[]) and each music into (header, (pitch, duration)[]), appending builtins for any referenced name that isn't user-declared.
  2. The IR codegen emits play Name as: write trigger bytes to $4002/$4003, load envelope pointer into $0C/$0D, set the sfx counter. start_music Name stamps a state byte into $07, loads the stream pointer into $0E/$0F (and the loop base into $05/$06), and primes the duration counter.
  3. The linker splices the audio tick, the 60-entry period table, and every compiled sfx/music blob into PRG ROM, all guarded on a __audio_used marker label so silent programs never pay the cost.

Runtime (every NMI, if audio is in use):

  1. SFX: if the counter is nonzero, read one envelope byte through (ZP_SFX_PTR),Y and write it to $4000. A zero sentinel mutes pulse 1 and stops the tick.
  2. Music: if active and the note counter hits zero, read the next pitch byte. 0 = rest (mute pulse 2). 1-60 = look up the period in the table and write to $4006/$4007. 0xFF = loop back to the base pointer (or mute if repeat: false). Then read the duration byte and reload the counter.

Total memory cost: 8 bytes of zero page, ~200 bytes for the driver body, 120 bytes for the period table, plus the data for each user-declared sfx/music.


Mappers

The mapper determines cartridge hardware and available ROM size.

Mapper PRG ROM CHR ROM Features
NROM 16 or 32 KB 8 KB No banking, simplest
MMC1 Up to 256 KB Up to 128 KB Switchable banks
UxROM Up to 256 KB 8 KB CHR RAM PRG banking only
MMC3 Up to 512 KB Up to 256 KB Scanline counter, banking

Bank Declarations

For mappers with bank switching:

bank MainCode {
    // Always-resident code (NMI handler, core engine)
}

bank Level1 {
    state Level1 { ... }
    background Level1BG { ... }
}

Banks can hold prg (code/data) or chr (graphics) content. Transitions between states in different banks automatically emit bank-switch and trampoline code.


Comments

// Line comment -- extends to end of line

/* Block comment
   spans multiple lines */

Includes

Split your game across multiple files:

include "physics.ne"
include "enemies.ne"

Includes are resolved relative to the including file. Circular includes are a compile error. Duplicate includes are skipped automatically.


Debug Mode

Compile with --debug to enable runtime instrumentation. All debug features are stripped completely in release builds (zero bytes, zero cycles).

Debug Logging

debug.log("Player position: ", px, ", ", py)

Debug Assertions

debug.assert(lives > 0, "Lives should never be negative")

Runtime Checks (Debug Only)

In debug mode, the compiler inserts:

  • Array bounds checking on indexed access
  • Arithmetic overflow warnings
  • Stack depth monitoring at function entry
  • Frame overrun detection (warns if frame handler exceeds vblank period)

Hardware Intrinsics

For the common case of reading or writing a single PPU/APU/mapper register, NEScript provides two built-in intrinsics:

poke(0x2006, 0x3F)        // write $3F to PPU address register
poke(0x2006, 0x00)        // (second half of the address)
poke(0x2007, 0x0F)        // write a palette byte to PPU data

var status: u8 = peek(0x2002)  // read PPU status register

The address argument to both is a compile-time constant. Zero-page addresses compile to STA $XX / LDA $XX; anything larger compiles to absolute addressing.

Inline Assembly

For more elaborate sequences, use asm { ... } blocks:

fun fast_shift(input: u8) -> u8 {
    var result: u8 = 0
    asm {
        LDA {input}
        ASL A
        ASL A
        STA {result}
    }
    return result
}

Inside an asm block, {name} is replaced with the resolved zero-page or absolute address of the variable name. Labels defined with name: are local to the block.

Raw Assembly

raw asm {
    LDA #$42
    STA $2007
}

raw asm skips variable substitution — {name} is passed through verbatim. Useful for completely unmanaged snippets that don't reference NEScript variables.


Error Codes

Lexer Errors (E01xx)

Code Description
E0101 Unterminated string literal
E0102 Invalid character
E0103 Number literal overflow

Type Errors (E02xx)

Code Description
E0201 Type mismatch
E0202 Invalid cast
E0203 Invalid operation for type

Memory Errors (E03xx)

Code Description
E0301 Zero-page overflow

Control Flow Errors (E04xx)

Code Description
E0401 Call depth exceeded
E0402 Recursion detected
E0403 Unreachable state
E0404 Transition to undefined state

Declaration Errors (E05xx)

Code Description
E0501 Duplicate declaration
E0502 Undefined variable
E0503 Undefined function
E0504 Missing start declaration
E0505 Multiple start declarations

Warnings (W01xx)

Code Description
W0101 Expensive multiply/divide operation
W0102 Loop without break or wait_frame
W0103 Unused variable
W0104 Unreachable code

Example Error Output

error[E0201]: type mismatch
  --> game.ne:42:15
   |
42 |   var x: u8 = -5
   |               ^^ expected u8, found negative integer
   |
   = help: use i8 if you need negative values: var x: i8 = -5
error[E0402]: recursion is not allowed
  --> game.ne:55:5
   |
55 |     flood_fill(x + 1, y)
   |     ^^^^^^^^^^^^^^^^^^^^
   |
   = note: flood_fill calls itself (directly recursive)
   = help: the NES has only 256 bytes of stack; use an iterative algorithm instead

Command Line

Compile a .ne source file into a .nes ROM:

nescript build game.ne
nescript build game.ne --output my_game.nes
nescript build game.ne --debug
nescript build game.ne --asm-dump
nescript build game.ne --dump-ir
Flag Description
--output Set output ROM file path (default: input.nes)
--debug Enable debug mode with runtime checks
--asm-dump Dump generated 6502 assembly to stdout
--dump-ir Dump the lowered IR program (after optimization) to stdout
--memory-map Dump a memory map of variable allocations to stdout
--call-graph Dump a call graph (which handler/function calls which) to stdout

Check

Type-check a source file without producing a ROM:

nescript check game.ne

Complete Example

A full game demonstrating states, input, functions, constants, and transitions:

game "Coin Cavern" {
    mapper: NROM
}

const SPEED: u8 = 2
const SCREEN_RIGHT: u8 = 240
const COIN_X: u8 = 180
const COIN_Y: u8 = 100

var player_x: u8 = 40
var player_y: u8 = 200
var score: u8 = 0
var coins_left: u8 = 3

fun clamp_x(val: u8) -> u8 {
    if val > SCREEN_RIGHT {
        return 0
    }
    return val
}

state Title {
    on frame {
        draw Logo at: (100, 100)
        if button.start {
            transition Playing
        }
    }
}

state Playing {
    on enter {
        player_x = 40
        player_y = 200
        score = 0
        coins_left = 3
    }

    on frame {
        if button.right {
            player_x += SPEED
            if player_x > SCREEN_RIGHT {
                player_x = SCREEN_RIGHT
            }
        }
        if button.left {
            if player_x >= SPEED {
                player_x -= SPEED
            } else {
                player_x = 0
            }
        }

        if player_x >= COIN_X {
            if player_y >= COIN_Y {
                score += 1
                coins_left -= 1
                if coins_left == 0 {
                    transition GameOver
                }
            }
        }

        draw Player at: (player_x, player_y)
        draw Coin at: (COIN_X, COIN_Y)
    }
}

state GameOver {
    on frame {
        draw Trophy at: (120, 100)
        if button.start {
            transition Title
        }
    }
}

start Title

Build and run:

nescript build coin_cavern.ne
# produces coin_cavern.nes -- open in any NES emulator