VPy and C are two ways into the same runtime. The VPy builtins are implemented once, in C, in libvpy (vpy.h in the SDK). VPy programs on the ARM targets and C games both link it, and a C game calls its functions directly. The 3D builtins call the SDK's vpy3d, which C games use too. So when you learn one language you have learned most of the other: the same coordinates, the same assets, the same frame model, often the same names.
This page lines the two up. For the C side in the IDE see C Projects, and for the full C API see libvpy and vpy3d.
Project files
| VPy | C | |
|---|---|---|
| Project file | <name>.vpyproj | <name>.cvproj |
| Entry | src/main.vpy | src/main.c |
| Who builds it | vpy_cli, the IDE's compiler | make, running the command in the .cvproj |
| Target | Chosen in Settings (or --target on the command line) | Chosen in Settings; each target has its own [targets.<t>] recipe |
| Assets | Found by folder under assets/ and named by string | Compiled to gen/<name>.h by the Makefile and named by symbol |
| SDK pin | [sdk] version | [sdk] version (same table) |
A new VPy project (File → New → Project, language VPy):
[project]
name = "ship"
version = "0.1.0"
entry = "src/main.vpy"
[build]
output = "build/ship.bin"
optimization = 2
debug_symbols = true
[sources]
vpy = ["src/**/*.vpy"]
[resources]
vectors = ["assets/vectors/*.vec"]
music = ["assets/music/*.vmus"]
# ... one line per asset folder
[sdk]
version = "v1.4.4"A new C project (language C (SDK)):
[project]
name = "ship"
type = "c-external"
target = "rp2350"
entry = "src/main.c"
[build]
command = "make"
args = ["rp2350"]
artifact = "build_rp2350/ship_sd.bin"
[targets.uvm2]
command = "make"
args = ["uvm2"]
artifact = "build_uvm2/ship.um2"
[simulate]
command = "make"
args = ["sim"]
module = "build_wasm/ship.js"
[sdk]
version = "v1.4.4"The full C manifest is in C Projects, and the pin in Projects and the SDK.
Program structure
Both languages have a run-once function and a per-frame function. Neither one has a frame loop to write.
# VPy
x = 0
def main():
SET_INTENSITY(127)
def loop():
x = clamp(x + J1_X() / 32, -100, 100)
PRINT_TEXT(-60, 60, "HELLO")
DRAW_LINE(x - 10, 0, x + 10, 0, 127)/* C */
#define VPY_SHORT_NAMES
#include <vpy.h>
static int x;
static void setup(void) { SET_INTENSITY(127); }
static void loop(void)
{
x = vpy_clamp(x + J1_X() / 32, -100, 100);
PRINT_TEXT(-60, 60, "HELLO");
DRAW_LINE(x - 10, 0, x + 10, 0, 127);
}
int main(void) { vpy_run(setup, loop); return 0; }| VPy | C | |
|---|---|---|
| Run once | def main(): | setup(), passed to vpy_run |
| Every frame | def loop(): | loop(), passed to vpy_run |
| Frame sync | WAIT_RECAL injected by the compiler at the start of loop() | vpy_run calls vpy_frame_begin() before loop() |
| Controls | Read once per frame by injected code | Read once per frame by vpy_frame_begin() |
| Music, SFX, notes | Updated every frame by injected code | Updated every frame by vpy_run |
Don't add a frame sync of your own in either language. In C, calling v_WaitRecal() directly skips the flush and nothing is drawn. If you need the boundary yourself, use vpy_wait_recal().
Builtins and their C calls
In C, #define VPY_SHORT_NAMES before #include <vpy.h> gives you VPy's upper-case names as macros. Without it, use the vpy_* names. The tables below list only pairs confirmed in vpy.h, in vpy3d's header or in the compiler's C backend. Short name is the C macro, and — means there is none: call the long name. Where the arguments differ, the C form is shown.
Drawing
| VPy | C short name | C function | Notes |
|---|---|---|---|
SET_INTENSITY(b) | SET_INTENSITY | vpy_set_intensity | |
MOVE(x, y) | MOVE | vpy_move | |
DRAW_LINE(x0, y0, x1, y1, b) | DRAW_LINE | vpy_draw_line | |
DRAW_CIRCLE(cx, cy, r, b) | DRAW_CIRCLE | vpy_draw_circle | 16 segments |
DRAW_RECT(x, y, w, h, b) | DRAW_RECT | vpy_draw_rect | x, y is the lower-left corner |
DRAW_FILLED_RECT(x, y, w, h, b) | DRAW_FILLED_RECT | vpy_draw_filled_rect | |
DRAW_ELLIPSE(cx, cy, rx, ry, b) | DRAW_ELLIPSE | vpy_draw_ellipse | |
DRAW_POLYGON(n, b, x0, y0, …) | DRAW_POLYGON | vpy_draw_polygon | C takes an array: (const int *xy, int n, int b) |
DRAW_VECTOR("ship", x, y) | DRAW_VECTOR | vpy_draw_vector | C: DRAW_VECTOR(SHIP_vec, x, y) |
DRAW_VECTOR_EX("ship", x, y, mirror, b) | DRAW_VECTOR_EX | vpy_draw_vector_ex | |
DRAW_VECTOR_ROT("ship", x, y, angle) | DRAW_VECTOR_ROT | vpy_draw_vector_rot | |
DRAW_VECTOR_ROT("ship", x, y, angle, mirror, b) | DRAW_VECTOR_ROT_EX | vpy_draw_vector_rot_ex | VPy has one name with optional arguments |
DRAW_ANIM("walk", x, y, mirror) | DRAW_ANIM | vpy_draw_anim | C: DRAW_ANIM(WALK_anim, WALK_anim_sprites, x, y, mirror) |
DRAW_RECORDING("clip", x, y, scale, frame) | DRAW_RECORDING | vpy_draw_recording | C always uses the recorded intensities |
WAIT_RECAL() (injected) | WAIT_RECAL | vpy_wait_recal | Done for you in both |
Text and debug
| VPy | C short name | C function | Notes |
|---|---|---|---|
PRINT_TEXT(x, y, "text") | PRINT_TEXT | vpy_print_text | |
PRINT_NUMBER(x, y, n) | PRINT_NUMBER | vpy_print_number | |
SET_TEXT_SIZE(n) | SET_TEXT_SIZE | vpy_set_text_size | |
DEBUG_PRINT(v) | — | vpy_debug_print | rp2350 builds: text to the IDE while debugged on the cartridge |
DEBUG_PRINT_LABELED("x", v) | DEBUG_PRINT_LABELED | vpy_debug_print_labeled | A no-op on targets with no console to print to |
DEBUG_PRINT_STR("text") | — | vpy_debug_print_str | |
| — | — | vpy_log(fmt, …) | C only: %d %i %u %x %c %s |
Input
| VPy | C short name | C function | Notes |
|---|---|---|---|
J1_X() / J1_Y() | J1_X / J1_Y | vpy_j1_x / vpy_j1_y | -127..127, +Y up |
J1_BUTTON_1() … J1_BUTTON_4() | J1_BUTTON_1() … J1_BUTTON_4() | vpy_j1_button(n) | 1 while held (level, not edge) |
UPDATE_BUTTONS() | UPDATE_BUTTONS | vpy_update_buttons | Not needed normally in either |
J2_X(), J2_BUTTON_1() … | — | — | libvpy has no joystick 2 calls |
Sound
| VPy | C short name | C function | Notes |
|---|---|---|---|
PLAY_MUSIC("theme") | PLAY_MUSIC | vpy_play_music | C: PLAY_MUSIC(THEME_music) |
STOP_MUSIC() | STOP_MUSIC | vpy_stop_music | |
PLAY_SFX("laser") | PLAY_SFX | vpy_play_sfx | Channel C. C: PLAY_SFX(LASER_sfx) |
PLAY_NOTE("bell", ch, note) | PLAY_NOTE | vpy_play_note | C: PLAY_NOTE(BELL_instr, ch, note) |
PLAY_SAMPLE("voice") | PLAY_SAMPLE | vpy_play_sample | C: PLAY_SAMPLE(VOICE_smp) |
SAMPLE_POS(fps) | SAMPLE_POS | vpy_sample_pos |
Levels, camera and enemies
| VPy | C short name | C function | Notes |
|---|---|---|---|
LOAD_LEVEL("level1") | LOAD_LEVEL | vpy_load_level | C: LOAD_LEVEL(LEVEL1_level, LEVEL1_level_sprites) |
SHOW_LEVEL() | SHOW_LEVEL | vpy_show_level | |
UPDATE_LEVEL() | UPDATE_LEVEL | vpy_update_level | |
SET_CAMERA_X(x) / SET_CAMERA_Y(y) | SET_CAMERA_X / SET_CAMERA_Y | vpy_set_camera_x / vpy_set_camera_y | |
GET_CAMERA_X() / GET_CAMERA_Y() | GET_CAMERA_X / GET_CAMERA_Y | vpy_get_camera_x / vpy_get_camera_y | |
GET_SCROLL_LIMIT_LEFT() (and _RIGHT, _TOP, _BOTTOM) | same names | vpy_get_scroll_limit_left … | |
GET_LEVEL_FLOOR_Y() | GET_LEVEL_FLOOR_Y | vpy_get_level_floor_y | |
LEVEL_COLLISION_Y(x, y, hh) | LEVEL_COLLISION_Y | vpy_level_collision_y | |
LEVEL_COLLISION_X(x, y, hw, hh) | LEVEL_COLLISION_X | vpy_level_collision_x | |
SPAWN_ENEMIES("level1") | SPAWN_ENEMIES | vpy_spawn_enemies | C: SPAWN_ENEMIES(LEVEL1_enemies, LEVEL1_enemy_sprites) |
UPDATE_ENEMIES() / DRAW_ENEMIES() | UPDATE_ENEMIES / DRAW_ENEMIES | vpy_update_enemies / vpy_draw_enemies | |
KILL_ENEMY(i) | KILL_ENEMY | vpy_kill_enemy | |
GET_ENEMY_ACTIVE(i), GET_ENEMY_X(i), GET_ENEMY_Y(i), GET_ENEMY_STATE(i) | same names | vpy_get_enemy_active, _x, _y, _state | |
SET_ENEMY_X(i, x), SET_ENEMY_Y(i, y), SET_ENEMY_STATE(i, s), SET_ENEMY_DIR(i, d) | same names | vpy_set_enemy_x, _y, _state, _dir | |
ENEMY_FIRE_EVENT(i, "hit") | ENEMY_FIRE_EVENT | vpy_enemy_fire_event_str | Hashed at run time like the VPy builtin |
Math
libvpy has no short names for the math helpers. Use the vpy_* names in C.
| VPy | C function | Notes |
|---|---|---|
abs(x) | vpy_abs | |
min(a, b) / max(a, b) | vpy_min / vpy_max | |
clamp(v, lo, hi) | vpy_clamp | |
sin(a) / cos(a) | vpy_sin / vpy_cos | 0..127 is a full turn; result -127..127 |
sqrt(x) | vpy_sqrt | Integer |
atan2(y, x) | vpy_atan2 | 0..127 |
rand() / rand_range(lo, hi) | vpy_rand / vpy_rand_range | |
SIN_Q14(a) / COS_Q14(a) | vpy_sin_q14 / vpy_cos_q14 | 4096 per turn, 16384 = 1.0. C short names SIN_Q14 / COS_Q14 |
| — | vpy_seed(s) | C only |
3D
The VPy 3D builtins call vpy3d, the same layer a C game includes with #include <vpy3d.h> (add $(VPY_C_SDK)/vpy3d.c to the sources). They are shaped differently, though. VPy names a mesh by string and keeps transforms in 12-element i32 arrays. C keeps a vpy_mesh object per mesh and passes vpy_xf values.
| VPy | C (vpy3d) |
|---|---|
XF_IDENTITY(xf) | xf = vpy3d_identity() |
XF_TRANSLATE(xf, x, y, z) | xf = vpy3d_translate(x, y, z) |
XF_ROT_X(xf, a) / XF_ROT_Y / XF_ROT_Z | vpy3d_rot_x(a) / vpy3d_rot_y(a) / vpy3d_rot_z(a) |
XF_MUL(dst, a, b) | dst = vpy3d_mul(&a, &b) |
XF_TO_MODEL(out, xf, x, y, z) | vpy3d_world_to_model |
CAMERA_LOOK_AT(ex, ey, ez, tx, ty, tz[, ux, uy, uz]) | vpy3d_look_at(ex, ey, ez, tx, ty, tz, ux, uy, uz) |
CAMERA_SET(xf) / CAMERA_GET(xf) | vpy3d_set_camera(&xf) / vpy3d_camera() |
CAMERA_FOCAL(f) / CAMERA_NEAR(z) / CAMERA_CLIP(hx, hy) | vpy3d_set_focal / vpy3d_set_near / vpy3d_set_clip_xy |
PROJECT(out, x, y, z) | vpy3d_to_camera then vpy3d_project |
PROJECT_CAM(out, x, y, z) | vpy3d_project |
SCREEN_SIZE(x, y, z, r) | vpy3d_screen_size |
LOAD_MESH("cube") | vpy3d_load_mesh(&cube, CUBE_vmesh) |
DRAW_MESH("cube", xf, b) | vpy3d_draw_mesh(&cube, &xf, b) |
MESH_OCCLUSION(on) | vpy3d_set_mesh_occlusion |
MESH_POOL_MARK() / MESH_POOL_RELEASE() | vpy3d_pool_mark() / vpy3d_pool_release(mark) |
LOAD_SCENE("tunnel") | vpy3d_load_scene(&s, TUNNEL_vscene, TUNNEL_vscene_meshes) |
DRAW_SCENE("tunnel", x, y, z, b[, groups]) | vpy3d_draw_scene(&s, 0, pos, groups, b) |
DRAW_SCENE_AT("tunnel", xf, x, y, z, b[, groups]) | vpy3d_draw_scene(&s, &xf, pos, groups, b) |
LINE_3D(…) / LINE_CAM(…) | vpy3d_line_world / vpy3d_line_cam |
OCCL_RESET() / OCCL_LINE(…) | vpy3d_occl_reset / vpy3d_occl_line |
OCCL_MESH("cube", xf) / OCCL_ADD(corners, n) | vpy3d_occl_add_mesh / vpy3d_occl_add |
RAY_MESH(out, "cube", xf, …) | vpy3d_ray_mesh |
MARKS_CLEAR / MARKS_ADD / MARKS_DRAW | vpy3d_marks_clear / vpy3d_marks_add / vpy3d_marks_draw |
FOG(b, x, y, z, full, gone) / SCENERY_FOG(full, gone) | vpy3d_fog / vpy3d_set_scenery_fog |
TEXT_BILLBOARD(…) / TEXT_3D(…) | vpy3d_text_billboard / vpy3d_text |
VEC_3D("ship", xf, scale, b) | vpy3d_draw_vec |
FX_RESET(), FX_SEED(s), FX_STEP(), FX_DRAW(), FX_DRAW2D() | vpyfx_reset, vpyfx_seed, vpyfx_step, vpyfx_draw, vpyfx_draw2d |
FX_RATE, FX_GRAVITY, FX_FLOOR, FX_BUDGET | vpyfx_set_rate, vpyfx_set_gravity, vpyfx_set_floor, vpyfx_set_budget |
SOFT_RESET(), SOFT_STEP(), SOFT_DRAW(…), SOFT_FREE(b), SOFT_PUSH(…), SOFT_UNPIN(…), SOFT_POINTS(b) | vpysoft_reset, vpysoft_step, vpysoft_draw, vpysoft_free, vpysoft_push, vpysoft_unpin, vpysoft_points |
The skeleton builtins (BONE_*) sit on vpybone, and the jukebox builtins (MUSIC_TRACK, SFX_PCM…) on the same player C games use, but through small adapters of the compiler's own, so their C calls are not one-to-one. See VPy 3D and vpy3d.
Assets
Both languages use the same files, made in the same editors. VPy names an asset with a string and the compiler embeds it. C includes the header vpy_cli compile-asset writes to gen/<name>.h and passes its arrays. In the table, N is the name in upper case.
| Asset | VPy | C | Where it builds |
|---|---|---|---|
.vec sprite | DRAW_VECTOR("n", x, y), DRAW_VECTOR_EX, DRAW_VECTOR_ROT, DRAW_VECTOR_3D | DRAW_VECTOR(N_vec, x, y), DRAW_VECTOR_ROT(N_vec, x, y, a) | VPy: all targets. C: sim, rp2350, uvm2 |
.vanim animation | DRAW_ANIM("n", x, y, mirror) | DRAW_ANIM(N_anim, N_anim_sprites, x, y, mirror) | All. C needs named .vec frames: inline paths are VPy only |
.vplay level | LOAD_LEVEL("n"), SHOW_LEVEL(), UPDATE_LEVEL() | LOAD_LEVEL(N_level, N_level_sprites), SHOW_LEVEL() | All |
.venemy enemy type | Placed by a level; SPAWN_ENEMIES("level") | SPAWN_ENEMIES(L_enemies, L_enemy_sprites), from the level's header | All |
.vmus music | PLAY_MUSIC("n"), STOP_MUSIC() | PLAY_MUSIC(N_music) | All |
.vsfx sound effect | PLAY_SFX("n") | PLAY_SFX(N_sfx) | All |
.vinstr instrument | PLAY_NOTE("n", ch, note) | PLAY_NOTE(N_instr, ch, note) | All |
.vrec recording | DRAW_RECORDING("n", x, y, scale, frame) | DRAW_RECORDING(N_vrec, x, y, scale, frame) | VPy: rp2350, uvm2, pitrex; video only on m6809. C: sim, rp2350, uvm2 |
.vsmp sample | PLAY_SAMPLE("n"), SAMPLE_POS(fps) | PLAY_SAMPLE(N_smp), SAMPLE_POS(fps) | Plays on rp2350 (and in the C simulator). Builds but is silent on uvm2. Not on m6809 |
.vmesh mesh | LOAD_MESH("n"), DRAW_MESH("n", xf, b) | vpy3d_load_mesh(&m, N_vmesh), vpy3d_draw_mesh | VPy: uvm2, and rp2350 with the C backend. C: sim, rp2350, uvm2 |
.vscene scene | LOAD_SCENE("n"), DRAW_SCENE("n", x, y, z, b) | vpy3d_load_scene(&s, N_vscene, N_vscene_meshes), vpy3d_draw_scene | As .vmesh; SDK v1.3.5 or later |
.vskel skeleton | import n, then n.T_BONES, n.T_TURNS, n.T_BONE_MESHES… | #include "n.h", then N_T_BONES… | VPy: uvm2. C: sim, rp2350, uvm2 |
.vclip poses and clips | import n, then n.T_POSES[n.POSE_X][n.JOINT_Y], n.T_KEYS… | #include "n.h", then N_T_POSES[N_POSE_X][N_JOINT_Y]… | VPy: uvm2. C: sim, rp2350, uvm2 |
"All" means m6809, rp2350, uvm2 and pitrex for VPy, and sim, rp2350 and uvm2 for C. In C, SET_INTENSITY does not override a sprite's own intensities. Give each asset of a C project its own name: ship.vec and ship.vmus would both become gen/ship.h.
Targets and languages
| Target | VPy | C |
|---|---|---|
| m6809 (Vectrex cartridge ROM) | Yes: .bin, single or multibank | No |
| pitrex (Raspberry Pi expansion; not yet tested with the v0.4 SDK) | Yes: .img, ARM32 | Only through the examples' common.mk (kernel7l.img, needs a PiTrex SDK); not in the new-project template |
| rp2350 (debug cartridge) | Yes: ARM Thumb-2, or C with [rp2350] backend = "c" | Yes: make rp2350, an SD card RAM image |
| uvm2 (UVMC2 multicart) | Yes: .um2, ARM Thumb-2 or C with [uvm2] backend = "c" | Yes: make uvm2, a .um2 |
| WASM simulator (Shift+F5) | No: VPy runs in the target's emulator | Yes: make sim, the fastest loop (no debugger) |
See Compiler & Build Targets for what each VPy target produces, and Cartridges for the hardware.
VPy built through C
On uvm2 and rp2350, a VPy program can be compiled as C. The compiler writes the whole program as C and builds it with gcc against libvpy, the same runtime C games link:
[uvm2]
backend = "c"
sdk_opt = "O2" # optional: "O2", "Os" or "O1"
defs = "UVM2_DRAW_SCALE=127" # optional; several are separated by ;
[rp2350]
backend = "c" # if absent, [uvm2] backend is used- Size. On a large game we measured 224 KB of code with the C backend against 323 KB with the ARM backend. A
.um2runs entirely from the cartridge's SRAM, code included, so this is room for levels. An untypedconstarray whose values all fit in a byte is stored asint8_twith this backend. - Same drawing as C games. The 2D builtins are libvpy's own, so text and vectors draw exactly as in a C game.
- rp2350 gets the 3D, effects and jukebox builtins this way. The result is always the SD card's RAM image. The cartridge has no audio jack, so the jukebox builtins compile and stay silent (
JUKEBOX_LIVE()returns 0). sdk_optbuilds the SDK's C libraries (libvpy, vpy3d and the layers on it) at that level instead of the build's own.defspasses defines to the SDK (pool sizes, limits). The[rp2350]keys fall back to[uvm2]ones.- Semantics are the ARM backend's: 32-bit wrapping integers, truncating
/, typed arrays. - Not everything is there yet. Builtins with no libvpy counterpart in this backend (levels, enemies, animations, samples) are a compile error for now.
- Debugging. gcc optimises, so a line may be skipped, a breakpoint on a call can stop in the inlined function, and some locals show as optimised out here.
main()andloop()appear in the call stack inlined into the runtime. See Debugging.
Shared conventions
| Both languages | |
|---|---|
| Screen | x and y from -127 to 127, origin at the centre |
| Y axis | +Y is up |
| Brightness | 0..127. In libvpy, 0 means "do not draw" |
| 2D angles | 0..127 is one full turn (sin, cos; DRAW_VECTOR_ROT turns counter-clockwise) |
| 3D angles | 4096 per turn (SIN_Q14, XF_ROT_*) |
| Frame | 50 Hz by default. Everything drawn in one loop() call is one frame |
| Asset coordinates | Integers, relative to the sprite's centre, +Y up |
On the ARM targets the C calls behave like the VPy ones, with the same bytes and the same runtime arithmetic, so positions and timings carry over when you port a game from one language to the other.