Cart-Forth subset

Cart-Forth is a Forth dialect that compiles to spriteCart's stack-VM bytecode. It's intentionally a subset of standard Forth — meta-Forth (custom defining words, DOES>, IMMEDIATE, dictionary introspection) is gone, the return stack isn't user-visible, and a handful of common words you'd reach for in gforth aren't here. Recursion works (words call themselves by name; tail calls are TCO'd to O(1) return-stack; non-tail calls use the 64-frame return stack). This page documents what's kept, what's cut, and the practical traps.

For an alphabetized table of every word with stack effects, see /docs/forth. For the wire-level HCALL ids that host words compile down to, see /docs/hcalls.

What's in

Stack manipulation

dup, drop, swap, over, rot, nip, tuck, 2dup. 16-bit signed cells; data stack depth 256, return stack depth 64.

Arithmetic + logic + comparison

+, -, *, /, mod, abs, negate, min, max, and, or, xor, invert, lshift, rshift, =, <>, <, >, <=, >=. All comparisons push 1 / 0 (no separate boolean type — see What's cut).

Memory

@ / ! (16-bit cell load/store), c@ / c! (byte load/store). Cart RAM is 8 KB, byte-addressable 0..=8191. The compiler reserves the bottom of RAM for variable + do/loop + S" allocations.

Defining words

: square dup * ;          \ word def: ( n -- n*n )
variable score             \ 2-byte RAM cell
42 constant magic-number   \ compile-time literal

: name body ; defines a new word; variable name allocates a 2-byte RAM cell; <value> constant name interns a compile-time literal. That's all the defining-word vocabulary — no create / allot for runtime-defined data structures.

Control flow

: classify ( n -- )
  dup 0< if  drop -1 exit  then
  dup 0= if  drop  0 exit  then
              drop  1 ;

: countdown ( n -- )
  begin
    dup .                    \ (no `.` yet — pretend; see What's cut)
    1 -
    dup 0=
  until drop ;

: paint-strip ( -- )
  10 0 do
    i 8 * 0 8 8 7 rect       \ i is the loop counter
  loop ;

if … then, if … else … then, begin … until (post-test loop), do … loop (bottom-test counted loop with i reading the loop counter), exit (early return from a definition).

String literals

: title-screen
  50 60 7 s" SNAKE" print
  44 76 7 s" PRESS X" print ;

S" text" allocates text's bytes into cart RAM at compile time and pushes ( addr len -- ) at runtime. The host print word consumes that shape. See the Gotchas section below for the space-after-quote rule.

Host words

Display (cls, pixel, rect, sprite, line, print, camera, clip), audio (note!, mute!, play-music, play-sfx, loop-sfx, stop-music, stop-sfx, set-bpm), input (btn, btnp). Each compiles to a single HCALL <id> instruction. Full table at /docs/forth.

What's cut

If your gforth muscle-memory reaches for one of these, it's not here. Most have a one-line workaround listed inline.

Standard ForthStatus in cart-ForthWorkaround
>r, r>, r@CutUse a variable as scratch — return stack isn't user-visible.
recurse / direct self-referenceKept (no keyword needed)Call yourself by name — no recurse keyword. Tail calls are TCO'd (O(1) return-stack); non-tail recursion uses the 64-frame return stack. Caveat: a recursive word must not re-enter its own do/loop (fixed RAM counter cells — use begin/until or a separate helper word inside the loop instead).
Nested do/loopCutInner loop in a separate word, or unroll. Compiler allocates one pair of RAM cells per source-position do, so nesting at the same source site reuses them.
unloop, leaveCutSet the loop counter equal to the limit on the line before loop.
DOES>, IMMEDIATE, CREATE / ALLOTCutNo meta-Forth. Pre-declare a variable and use it as the table base; <addr> constant table-base for a stable name.
true, falseCutUse 1 and 0. Comparison primitives already push these.
. (print top of stack as number)Cut in v1No number→string formatter. Build the digits yourself and print them, or wait for a future host word.
." text" (compile-and-print)Lexer accepts, compiler rejectsUse S" text" + the print HCALL. v1 doesn't bake printing into a literal.
Dictionary introspection (find, ', ['], execute)CutWord addresses are compile-time-fixed; the cart can't look up by name at runtime.
abort", exception machineryCutHost emits a VM error that lands in the studio console drawer / #error-pane on /play — see cart-format.

Gotchas

do/loop is a bottom-test loop

Cart-Forth's do/loop tests at the BOTTOM, so 0 0 do … loop runs once (then increments to 1, sees 1 ≥ 0, exits). Standard Forth's do/loop tests at the top and would skip the body entirely. Guard with a manual <count> 0> if … then when the count might be zero.

S" requires a space after the open-quote

: ok    0 0 7 s" hello" print ;     \ space after open-quote — works
: bad   0 0 7 s"hello" print ;      \ no space — lexer rejects

The lexer treats S" as a token by itself; the literal content starts at the next whitespace-delimited token. S"text" without the space is a single lexer token and won't parse.

mod is signed-remainder, not Euclidean

Rust-style: -5 3 mod pushes -2, not 1. For positive-result wrap-around (toroidal sprite coords, ring buffers), use explicit edge-snaps: if x < 0 then x + 128 rather than x + 128 128 mod.

Variables are not re-entrant

Each variable is a single RAM cell — a word that writes to it and then calls another word (or itself) that also writes to it will clobber the outer value. With recursion supported, this is a real hazard: recursive words and mutual-recursive helpers must not share a scratch variable without explicit save/restore via two named cells.

do/loop counter cells are also not re-entrant

Each source-position do allocates two cart-RAM cells (start + limit). A word containing do/loop that's invoked from inside another do/loop of the same body will overwrite those cells. Practical rule: keep loops one level deep per word.

Memory + execution model

  • Cells are 16-bit signed. Stack overflow / underflow halts the VM with a runtime error.
  • Cart RAM: 8 KB byte-addressable. Top of RAM is yours; bottom is the compiler's bump-allocator for variable + do/loop + S" content.
  • Initial RAM image: S" bytes are packed into the RAMI cart section and memcpy'd into RAM at cart load — strings are addressable from frame 0.
  • Cycle budget is machine-specific. SC8 charges 1 cycle per op against a 500,000-op per-frame budget; when exhausted, the per-frame update yields mid-execution and the in-flight call chain gets dropped at the next frame's vm.pc reset. The runner emits a rate-limited warning when this happens — see cart-format.
  • GameTank charges measured native cycle costs per op and per draw against a frame budget of 59,009 cycles (55,509 when the cart carries a SOND — music playback reserves extra per-frame headroom). Unlike SC8, an over-budget GameTank frame still runs its game logic to completion; the runner instead drops to an honest lower effective framerate (with a rate-limited warning) rather than dropping the call chain. In-budget GameTank carts run a true 60 fps.
  • Two lifecycle entry points: init runs once at cart load (optional; useful for one-time RAM seeding); update runs every frame at 60 Hz. Both are registered automatically — define them in your source and the cart's SYMS section picks them up.

Placement pragmas (GameTank native)

Native GameTank ROM builds (the studio's Export .gtr button, or a source-tree cart-build --emit gtr build) can be steered with whole-line comment directives in cart-Forth source. These pragmas control bank placement only for a native .gtr export — they are inert everywhere else: the browser VM and SC8 carts ignore them entirely, and they never change a cart's compiled bytecode or .sprite bytes.

A directive line's first non-whitespace token must be \ and the second must be the directive name. Inline comments, ( ... ) comments, and mid-line \ @hot text are just ordinary comments — only a whole line starting with \ is recognized as a pragma.

DirectiveEffect
\ @hot w1 w2 …Pin these words into the always-mapped fixed ROM bank, in the order listed. Multiple @hot lines accumulate (file order, deduped keeping first occurrence).
\ @same-bank w1 w2 …Force this group of words to land in the same PROG bank. Multiple lines are independent groups; a word appearing in two groups merges them.
\ @steady-root w1 …Declare steady-state entry points, used to filter the placement report's *steady-state* edge flags.
\ @hot draw-aliens move-player check-collision
\ @same-bank shield-hit shield-erode shield-draw
\ @steady-root play-frame bump-frame

@hot order matters: the fixed ROM bank has a finite budget, and words are seeded into it in the order they appear across @hot lines until the budget runs out. Put your hottest / most cross-bank-called words first — anything that doesn't fit is demoted back into an ordinary PROG bank, with a warning.

The placement report (printed to the studio console on every Export .gtr) lists exactly which bank each word landed in and every cross-bank call edge — the fastest way to confirm a pragma had the effect you expected.

Writing fast carts (GameTank)

GameTank charges every op its measured native cycle cost against a per-frame budget — 59,009 cycles, or 55,509 when the cart carries music. A cart that stays under budget runs a true 60 fps in the browser and on real hardware; going over drops the honest effective framerate (60 → 30 → 20 …) and prints a console warning (frame cost X/<budget> cyc — ~30 fps on GameTank, against whichever budget your cart is charged) so you always know where you stand. These are the habits that keep real carts under budget — each one earned its place in GT Invaders.

The compiler already handles constant arithmetic. Constant multiplies like 30 * or 6 * compile to cheap shift-add chains automatically (whenever the constant splits into up to three power-of-two terms), and literal adds like 5 + fuse into a single op. Don't hand-write shift tricks — write the natural arithmetic.

Gate your loops — don't scan-and-skip. Loop bookkeeping alone costs roughly 300 native cycles per iteration, before your loop body runs a single op. A fixed 55-cell scan that usually finds nothing to do burns ~15,000+ cycles every frame just iterating. Keep a counter or flag and exit the word before the loop starts:

variable explosions-active     \ count of cells in a dying state

: tick-explosions
  explosions-active @ 0 = if exit then   \ steady frames: 3 ops, done
  55 0 do
    \ ... per-cell decay work, only when something is actually dying ...
  loop ;

Use c@ / c! for byte-sized state. A byte load/store costs 24/30 cycles against 42/52 for the 16-bit @ / ! — in a state-heavy cart that difference is worth ~10% of the whole frame. Two hard rules before narrowing a variable: its value must always fit 0–255, and it must never go negativec@ zero-extends, so a stored -1 reads back as 255. Keep scores, RNG state, direction values (±1), and unbounded counters 16-bit.

Division stays expensive. /, mod, and /mod cost 570 cycles every time — there is no automatic strength reduction for them. If the inputs only change on an event (a kill, a wave transition), compute the result there and store it, rather than re-dividing every frame. For power-of-two divisors you can strength-reduce by hand: when the value is known non-negative, 4 / is 2 rshift (~60 cycles instead of 570), and a divisibility test like 4 mod 0 = is 3 and 0 = — the mask form of the is-divisible test is exact for every 16-bit value, sign included, while the shift form of division is only safe for non-negative inputs.

Reject cheap before testing expensive. A general rectangle-overlap word costs ~850 cycles per call, so a collision loop that AABBs every bullet against every target every frame dominates busy frames fast. Hoist the shared axis out of the loop (all four shields sit in one y-band — one compare rejects the whole loop while a bullet is above them), and when targets sit at fixed arithmetic positions, compute the only candidate from the bullet's x instead of looping all four. GT Invaders' bullet-vs-shield chain dropped from 28% of gameplay frames over budget to ~2% from this kind of behavior-identical gating alone.

Budget per-frame, not on average. The fidelity throttle stretches each over-budget frame individually, so a cart whose average frame is comfortably in budget can still read as 30 fps whenever expensive frames cluster (several bullets crossing the shields at once, say). Check the per-frame picture, not just the mean: cart-build --profile <frames> prints over-budget frame counts and the longest consecutive over-budget run, and --profile-frame-dump <csv> writes every frame's modeled cost so you can find exactly which game situations spike.

Draw incrementally. cls costs ~17,000 cycles — over a quarter of the budget — and a blit costs w×h + 220. Erase and redraw only what moved (a black rect over the old position, then the new blit) instead of clearing and repainting the scene. Text is 300 cycles per character, so reprint numbers only when the value changes.

For where your code lands in the ROM (cross-bank call overhead on native builds), see Placement pragmas above; for the machine's budget model itself, see The GameTank machine.