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 -> .um2Every 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.mkuvm2.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.
| Variable | Default | What it does |
|---|---|---|
UVM2_DUAL_CORE | 1 | Core 1 replays the list and reads input while core 0 builds the next frame. |
UVM2_PIO_STREAM | 1 | The replay goes out through PIO + DMA instead of the CPU driving the GPIO. |
UVM2_HZ | 50 | Refresh cap. 60 for 60 Hz mains, 0 presents as soon as the list is ready. |
UVM2_CMD_CAPACITY | 8192 | Commands the list can hold: 3 bytes each, per buffer, two buffers. Watch stats.dropped. |
UVM2_LIST_MAX | 64 in dual core | Words in the single-core PIO stream buffer. Dual-core games get the minimum and keep 98 KB of SRAM free. |
UVM2_CMDS_IN_PSRAM | off | Put the command list in PSRAM. Frees SRAM, costs determinism. |
UVM2_ROMZIP_IN_PSRAM | auto | Put the romset buffer in PSRAM (write once, read once: the ideal case). |
UVM2_SRCS_DROP | libc_stub.c | Sources dropped on this path (the pico-sdk brings newlib; hand-written stubs collide). |
UVM2_UM2_ONLY | empty | Flags 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 framesAdd 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.