libvpy is the top layer of the SDK and the one most games should use. It is a small game library in plain C. It is integer-only on purpose, with no math.h, so it compiles to plain integer ARM code that runs the same everywhere.
It is also backend-neutral. libvpy is written against the PiTrex/host contract, so the same game source builds for:
- the Vectrex Studio Debug Cart;
- the UVMC2;
- the desktop harness and the WASM simulator;
- PiTrex, coming soon.
To use it, #include <vpy.h> and add $(VPY_C_SDK)/vpy.c to UVM2_SRCS.
The SDK has three layers, and they can be mixed freely:
| Level | Header | What it is |
|---|---|---|
| 1 — libvpy | sdk/vpy-c/include/vpy.h (+ the modules on the next pages) | A game library. Most games live here. |
| 2 — PiTrex/host contract | vectrex/vectrexInterface.h | The backend-neutral surface: about 20 declarations. |
| 3 — the cartridge SDK | sdk/uvm2-sdk/*.h | The cartridge runtime itself. |
Units
| Space | Range | Used by |
|---|---|---|
| VPy logical | x and y from −127 to +127, +y up, origin at the centre | libvpy |
| PiTrex | VPy × 127, so about ±16 000 | v_directDraw32 |
| device | the SDK's own unit | uvm2_draw_move_abs, uvm2_zero_jump |
| subunit (q4) | 1/16 of a device unit | the *_q4 calls |
| brightness | 0..127; 0 means "do not draw" | everywhere |
| angle | 0..127 is a full circle | vpy_sin, vpy_cos, vpy_atan2 |
| bus cycle | 667 ns; 30 000 of them make a 50 Hz frame | uvm2_exec, uvm2_stats |
Lifecycle
void vpy_init(void); /* vectrexinit + v_init + v_setRefresh */
void vpy_frame_begin(void); /* v_WaitRecal + refresh the input snapshot */
void vpy_run(void (*setup)(void), void (*loop)(void)); /* never returns */vpy_run is the whole main loop. It initialises, calls setup() once, and then, every frame, calls vpy_frame_begin(), vpy_music_update(), vpy_sfx_update() and your loop(). There is no "present" call: everything drawn inside loop() is one frame.
#include <vpy.h>
static int s_x;
static void setup(void) { s_x = 0; }
static void loop(void)
{
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);
vpy_draw_line(s_x - 10, 0, s_x + 10, 0, 110);
}
int main(void) { vpy_run(setup, loop); return 0; }Drawing
void vpy_set_intensity(int b);
void vpy_move(int x, int y); /* set the cursor */
void vpy_draw_line(int x0,int y0,int x1,int y1,int b);
void vpy_draw_circle(int cx,int cy,int r,int b); /* 16 segments */
void vpy_draw_ellipse(int cx,int cy,int rx,int ry,int b); /* 16 segments */
void vpy_draw_rect(int x,int y,int w,int h,int b); /* x,y = lower-left */
void vpy_draw_filled_rect(int x,int y,int w,int h,int b); /* hatched, 3 units apart */
void vpy_draw_polygon(const int *xy,int n,int b); /* n vertices, xy[2n] */
void vpy_draw_line_dev(int32_t x0,int32_t y0,int32_t x1,int32_t y1,int b); /* deflection units */The stroke buffer
Every drawing call goes through a stroke buffer that holds 1024 strokes. The strokes reach v_directDraw32 only when the frame is flushed, which happens in vpy_run, vpy_frame_begin or vpy_wait_recal.
A game that calls
v_WaitRecal()directly never flushes and draws nothing. Ifvpy_draw_stats()->framesstays at 0, this is why. Usevpy_wait_recal()instead.
void vpy_flush(void);
void vpy_wait_recal(void); /* flush, then v_WaitRecal */
void vpy_set_priority(int p); /* VPY_PRI_LOW 0 / NORMAL 128 / KEEP 255 */
int vpy_pending_strokes(void);
const vpy_draw_stats_t *vpy_draw_stats(void); /* frames, strokes, peak, shed, dropped, clamped */
int vpy_sin_q14(int a), vpy_cos_q14(int a); /* 4096 steps per turn, 16384 = 1.0 */When the buffer is full, a new stroke either evicts a lower-priority one or is dropped. This choice is made by the game library, not the SDK: the SDK always draws exactly what it is given. Both cases are counted. If shed or dropped is not zero, the frame on screen is not the frame the game built.
The effects, soft-body and rope modules draw at VPY_PRI_LOW. That makes them the first strokes shed when a frame is full, so the scenery survives.
Drawing cost
On the cartridges, a lit stroke costs about the same whatever its length, and a blanked jump between strokes costs extra. So chain your strokes (start each one where the last ended), prefer fewer and longer strokes, and don't set an intensity again if it hasn't changed. See Drawing.
Compiled assets
void vpy_draw_vector(const unsigned char *data,int x,int y);
void vpy_draw_vector_ex(const unsigned char *data,int x,int y,int mirror,int intensity);
void vpy_draw_anim(const unsigned char *anim,
const unsigned char *const *sprites,int x,int y,int mirror);datais a position-independent path stream: a compiled.vecfile.animis a frame descriptor plus a matching table of sprite pointers (.vanim).- Bézier segments are tessellated into lines.
intensityof 0 or less keeps each path's own intensity.
The asset compiler (vpy_cli compile-asset, part of Vectrex Studio) is not in the starter kit. The readers are, and the formats are documented in vpy.c's comments.
Text
void vpy_set_text_size(int s);
void vpy_print_text(int x,int y,const char *s);
void vpy_print_number(int x,int y,long n);Glyphs advance by different amounts, so measure a string's width rather than computing it. examples/hello_uvmc2/tools/text_metrics.c does that.
Input
int vpy_j1_x(void); /* -127..127 */
int vpy_j1_y(void); /* -127..127, + = up */
int vpy_j1_button(int n); /* n = 1..4 -> 0/1 */
void vpy_update_buttons(void); /* force a re-read mid-frame */Input is sampled once per frame, between frames, and cached, so reading it costs nothing. By default the stick reads as digital (−127, 0 or +127), so ordinary if (x > 32) code behaves as it would on a real cartridge. See Input for analog mode.
Math
int vpy_abs, vpy_min, vpy_max, vpy_clamp;
int vpy_sin(int a), vpy_cos(int a); /* a 0..127 = full circle -> -127..127 */
int vpy_sqrt(int v); /* integer Newton */
int vpy_atan2(int y,int x); /* -> 0..127 */
int vpy_rand(void), vpy_rand_range(int lo,int hi);
void vpy_seed(unsigned s);Sound
void vpy_beep(int on);
void vpy_tone(int period,int volume); /* channel A; period 12-bit, vol 0..15 */
void vpy_play_music(const unsigned char *vmus); /* a compiled PSG event stream */
void vpy_music_update(void); /* auto-called by vpy_run */
void vpy_stop_music(void);
void vpy_play_sfx(const unsigned char *vsfx); /* one-shot, channel C */
void vpy_sfx_update(void);Music (.vmus) and effects (.vsfx) play on the console's AY-3-8912. For digitised samples and the UVMC2's 16-bit jack, see Sound.
Levels and enemies (optional)
libvpy includes a scrolling-level runtime without tiles, with these calls:
- Levels:
vpy_load_level,vpy_show_level,vpy_update_level, the camera accessors, andvpy_level_collision_x/y. - Enemies: a pool with patrol, area and wander AI:
vpy_spawn_enemies,vpy_update_enemies,vpy_draw_enemies,vpy_kill_enemy, and per-enemy accessors.
It reads compiled .vplay images, the same format Vectrex Studio's level and enemy editors produce. Code you don't call is removed by --gc-sections, so ignoring this part costs nothing.
The modules
On top of libvpy, each of these is one .c file to add to UVM2_SRCS:
| Module | Page |
|---|---|
vpy3d: meshes, camera, occlusion, 3D text, stereo | vpy3d |
vpyphys, vpyimpact: rigid bodies and impact sounds | Physics |
vpyfx, vpycam, vpyease: sparks, shattering, camera, easing | Effects & camera |
vpybone, vpyik, vpysoft, vpyrope: skeletons, IK, soft bodies, ropes | Animation |
vpyent, vpyai, vpyreplay: entities, steering, replays | Gameplay |
Every module is integer-only and deterministic: the same calls give the same result on every target, bit for bit. Every module counts what it refuses or sheds rather than failing silently, and every one has a check program in sdk/vpy-c/tools/ that tests it against its header.