All SDK docs

Your First Game

The build chain from Makefile to .um2, the smallest game that works, and the build options that matter.

A game is a C file with a setup function and a per-frame loop, plus a Makefile that names the game and its sources. The SDK supplies everything else.


The build chain

your Makefile                      names the game and its sources
  └─ include $(UVM2_SDK)/uvm2.mk   turns that into a cmake invocation
       └─ sdk/uvm2-sdk/pico/CMakeLists.txt
            └─ uvm2_pico.cmake     the real build: SDK sources, Rust crates, flags
                 └─ pico-sdk 2.2.0 crt0, IMAGE_DEF, clocks, multicore, PIO, DMA
                      └─ arm-none-eabi-gcc  ->  .elf  ->  .bin
                           └─ sdk/tools/package_um2.py  ->  .um2

Every path is derived from the Makefile's own location, so the kit builds from wherever it was unpacked.


The smallest Makefile

This is examples/hello_uvmc2/Makefile, trimmed to the lines that matter:

UVMC2_KIT   ?= $(abspath $(dir $(lastword $(MAKEFILE_LIST)))../..)
UVM2_SDK    ?= $(UVMC2_KIT)/sdk/uvm2-sdk
VPY_C_SDK   ?= $(UVMC2_KIT)/sdk/vpy-c
PITREX_INC  ?= $(UVMC2_KIT)/sdk/pitrex-sim/include
 
UVM2_NAME    = hello_uvmc2              # -> build_uvm2/hello_uvmc2.um2
UVM2_SRCS    = src/main.c $(VPY_C_SDK)/vpy.c
UVM2_CC      = arm-none-eabi-gcc
UVM2_CFLAGS  = -mthumb -mcpu=cortex-m33 -mfloat-abi=soft -ffreestanding -O2 -std=gnu11 \
               -ffunction-sections -fdata-sections -DVPY_RP2350 \
               -Isrc -I$(VPY_C_SDK)/include -I$(PITREX_INC)
 
include $(UVM2_SDK)/uvm2.mk

uvm2.mk adds the game-facing backend, the whole runtime, the Rust crates, the linker script, the startup code and the packaging step.


The smallest game

Cut down from examples/hello_uvmc2/src/main.c to what every game has: setup, a per-frame loop, input and drawing.

#include <vpy.h>
 
static int s_x;                          /* VPy units: -127..127, +y up, (0,0) centre */
 
static void setup(void) { s_x = 0; }
 
static void loop(void)                   /* once per frame, input already sampled */
{
    if (vpy_j1_x() >  32) s_x++;
    if (vpy_j1_x() < -32) s_x--;
    s_x = vpy_clamp(s_x, -100, 100);
 
    vpy_print_text(-60, 100, "HELLO");
    vpy_draw_rect(-120, -120, 240, 240, 40);          /* brightness 0..127 */
    vpy_draw_line(s_x - 10, 0, s_x + 10, 0, 110);
}
 
int main(void)
{
    vpy_run(setup, loop);                /* never returns */
    return 0;
}

There is no explicit "present" call. vpy_run calls vpy_frame_begin() before each loop(), which seals the previous frame and hands it to core 1 (see The command list). Everything drawn inside loop() is one frame. The full game library is described in libvpy.

Starting a new game

Copy examples/hello_uvmc2/ anywhere inside the kit and change UVM2_NAME and UVM2_SRCS in its Makefile. That is all.


The build options that matter

All of these are make variables. Their defaults live in uvm2.mk, each with a comment explaining why.

VariableDefaultWhat it does
UVM2_DUAL_CORE1Core 1 replays the list and reads input while core 0 builds the next frame.
UVM2_PIO_STREAM1The replay goes out through PIO + DMA instead of the CPU driving the GPIO.
UVM2_HZ50Refresh cap. 60 for 60 Hz mains, 0 presents as soon as the list is ready.
UVM2_CMD_CAPACITY8192Commands the list can hold: 3 bytes each, per buffer, two buffers. Watch stats.dropped.
UVM2_LIST_MAX64 in dual coreWords in the single-core PIO stream buffer. Dual-core games get the minimum and keep 98 KB of SRAM free.
UVM2_CMDS_IN_PSRAMoffPut the command list in PSRAM. Frees SRAM, costs determinism.
UVM2_ROMZIP_IN_PSRAMautoPut the romset buffer in PSRAM (write once, read once: the ideal case).
UVM2_SRCS_DROPlibc_stub.cSources dropped on this path (the pico-sdk brings newlib; hand-written stubs collide).
UVM2_UM2_ONLYemptyFlags that apply to the .um2 only, not to a shared build.

Dual core and PIO are not optional

The runtime refuses to compile without both. A default can be switched off without anyone noticing, and when it was, a game drew for 51 ms and ran its logic for 46 ms in series: 97 ms per frame, purely for not having core 1. With both, the same game runs at 40 ms per frame. The escape hatch, UVM2_BENCH_NO_CORE1, exists for benches that measure the executor in isolation. A game should never declare it.

50 Hz is a choice, not a fact

The Vectrex has no vsync: it is a vector monitor and redraws when told to. Arcade vector machines had no fixed refresh either; Asteroids redrew as soon as it finished its list, which is why it dimmed as the screen filled. Pinning 50 Hz only holds a game back when it has room to spare.

When comparing refresh rates, remember that refreshing more often also makes the picture brighter: more passes per second over the same phosphor. If it "looks better at 60", some of that is brightness, not smoothness.


Core 0 has 4 KB of stack

Your game runs on core 0 with 4 KB of stack, and core 1's stack, the core that replays the list, sits right below it. Overflow it and nothing reports a stack overflow: core 1's stack is overwritten and the console hangs.

Big local arrays belong in static storage, not on the stack. To check your own call paths:

arm-none-eabi-gcc <your flags> -fstack-usage -c src/main.c   # writes main.su
sort -t$'\t' -k2 -n -r *.su | head                            # the biggest frames

Add up the frames along your deepest call path and keep well under 4096.

The running image also measures itself. Both stacks are painted at boot, and uvm2_stats.stack0_peak / stack1_peak report the deepest each has reached, in bytes; stack_overflow is sticky (bit 0 for core 0, bit 1 for core 1). Read them on screen with the diagnostics HUD (hold buttons 1 and 4 for two seconds) or over SWD. A heavy physics demo with everything colliding peaks at about 2.6 KB of the 4 KB: there is room, but not for a few more 1 KB local arrays.

Debug Cart: games run on a core with more stack there, so a game that overflows on the UVMC2 can run fine on the Debug Cart. Test stack depth on the UVMC2.


What ends up in the image

One binary contains:

  • your sources and the game-facing API (sdk_rp2350.c);
  • the whole runtime: bus, draw, input, text, LED, SD, romzip, audio, samples, the syscall handler and the core 1 loop;
  • the two Rust libraries (beam model and bus stream), built into the game's own build directory, never into the SDK, so two games built at once cannot link each other's;
  • the pico-sdk pieces it needs: crt0, runtime init, multicore, PIO, DMA, flash.

Always link through the CMake path. An older hand-written link (uvm2_start.s) produces images that boot but draw a ghost segment from the origin every frame; it stays in the kit only as documentation of the vector table and the IMAGE_DEF block.

Next: Examples, or how the beam works in Drawing.