All posts

v0.3.0 — PiTrex, enemy system, animations, and a new level editor

PiTrex ARM32 backend tested on real hardware, a complete enemy state machine system, .vanim vector animations, wander AI with platform navigation, a fully redesigned level editor, and much more.

releasevectrexvpypitrexenemiesanimationleveleditor

v0.3 is the biggest release since Vectrex Studio launched. It adds a complete enemy system, a new animation format, a working ARM32 backend for PiTrex hardware, and replaces the old physics sandbox with a real game-level editor. There is also experimental support for two new targets — RP2350 and UVM2 — that are still in early development.


PiTrex — ARM32 backend, tested on real hardware

PiTrex is an expansion board for the Vectrex that replaces the original cartridge slot with a Raspberry Pi Zero. The Pi drives the beam directly at high speed, which allows far more geometry per frame than the original hardware. Vectrex Studio now compiles VPy games to ARM32 code that runs natively on PiTrex.

The backend covers the full VPy builtin set: all drawing primitives, PRINT_TEXT / PRINT_NUMBER, the full level engine (LOAD_LEVEL / SHOW_LEVEL / UPDATE_LEVEL), the enemy system, audio (PSG music + SFX), and all joystick inputs for both players. The IDE emulator renders PiTrex output with its own vector renderer and PSG audio emulation, so you can develop without hardware.

To target PiTrex, set target = "pitrex" in your .vpyproj:

[build]
target = "pitrex"
output = "build/mygame"

The compiler emits ARM32 assembly that links against the PiTrex SDK. The resulting ELF binary can be copied directly to the Pi SD card.

Overlay images

PiTrex projects can include an overlay PNG that appears behind the vector display in the IDE emulator — useful for seeing platform geometry and sprite reference art while developing.


Experimental targets: RP2350 and UVM2

Two more targets were added in this release. Both are experimental and not ready for production games.

RP2350 / ARM Thumb2 — compiles VPy to Thumb2 code for the Raspberry Pi RP2350 microcontroller. The IDE emulator can run the generated binaries, but no physical cartridge has been built for hardware verification. Consider this a development preview.

UVM2 — early scaffolding for a third-party vector display system. The compiler can generate code for this target, but we have not been able to run a UVM2 ROM due to incomplete format documentation. Not useful for games yet.


Enemy system — .venemy, state machines, pool builtins

v0.3 adds a complete enemy system. Enemies are defined in .venemy JSON files that describe their states, sprites, and transitions. The compiler reads these files at build time and emits ROM tables that the runtime state machine uses each frame — no interpreter overhead, no heap allocation.

Defining an enemy type

A .venemy file maps states to animations and outgoing transitions:

{
  "name": "titchi",
  "ai_type": 2,
  "patrol_speed": 1,
  "sprite": "titchi_walk",
  "state_machine": {
    "walk": {
      "sprite": "titchi_walk",
      "transitions": [
        { "event": "hit_snow", "next": "snow1" }
      ]
    },
    "snow1": {
      "sprite": "titchi_snow1",
      "transitions": [
        { "event": "hit_snow", "next": "snow2" },
        { "event": "timer_out", "next": "walk" }
      ]
    },
    "snow2": {
      "sprite": "titchi_snow2",
      "transitions": [
        { "event": "timer_out", "next": "walk" }
      ]
    }
  }
}

Builtins

Enemies are spawned from spawn-point objects placed in the level editor and managed via a pool:

def loop():
    SPAWN_ENEMIES()       # spawn enemies whose spawn point is on screen
    UPDATE_ENEMIES()      # advance AI, physics, and state machines
    SHOW_LEVEL()
    DRAW_ENEMIES()        # draw each active enemy
 
    # Manual control
    if FIRE_EVT(enemy_idx, "hit_snow"):
        pass              # fires the hit_snow transition on enemy at idx
 
    KILL_ENEMY(enemy_idx)
    SET_ENEMY_STATE(enemy_idx, "walk")
    SET_ENEMY_DIR(enemy_idx, 1)   # 1 = right, -1 = left

The enemy pool is a fixed-size array in RAM. Each slot stores position, state, AI sub-state, direction, thaw timer, and a pointer to the type data ROM table. GET_ENEMY_* and SET_ENEMY_* read and write individual fields:

x = GET_ENEMY_X(idx)
y = GET_ENEMY_Y(idx)
SET_ENEMY_X(idx, new_x)

Animation system — .vanim and DRAW_ANIM

Vector animations are stored in .vanim files — JSON arrays of frames, each referencing one or more .vec asset names by string and a per-frame duration in screen ticks (50 Hz):

{
  "version": "1.0",
  "name": "titchi_walk",
  "loop": true,
  "base_refs": [],
  "frames": [
    {
      "index": 0,
      "duration_ticks": 16,
      "vec_refs": ["titchi_body", "titchi_feet_a"],
      "paths": []
    },
    {
      "index": 1,
      "duration_ticks": 16,
      "vec_refs": ["titchi_body", "titchi_feet_b"],
      "paths": []
    }
  ]
}

A base_refs field lists static .vec references drawn before every frame — useful for a character body that doesn't change while only the feet animate:

{
  "version": "1.0",
  "name": "titchi_walk",
  "loop": true,
  "base_refs": ["titchi_body"],
  "frames": [
    { "index": 0, "duration_ticks": 16, "vec_refs": ["titchi_feet_a"], "paths": [] },
    { "index": 1, "duration_ticks": 16, "vec_refs": ["titchi_feet_b"], "paths": [] }
  ]
}

The optional paths array on each frame lets you embed vector geometry inline — handy for quick iteration in the AnimationEditor without creating separate .vec files. The compiler converts inline paths into synthetic vec assets at build time, so they render identically on M6809, PiTrex, and RP2350 with no per-target authoring difference.

Play it with DRAW_ANIM:

def loop():
    DRAW_ANIM("titchi_walk", player_x, player_y)

The compiler auto-advances the frame counter based on the FPS setting. You do not manage frame timing in game code.

AnimationEditor

The IDE has a new AnimationEditor panel for editing .vanim files. Frames are shown as a filmstrip; clicking a frame opens a canvas view that renders the referenced .vec geometry at actual size. You can drag cels, adjust offsets, and preview the animation playing back in real time.


Wander AI — autonomous platform navigation

Enemies with ai_type = 4 use the wander system: a state machine that navigates between platforms without any scripted waypoints. The system works by defining walkable areas on each .vec platform asset, then letting the AI find its own path at runtime.

Walkable areas

A walkable area describes a horizontal shelf that an enemy can walk and stand on. Areas can be defined directly on a .vec file:

{
  "walkable_areas": [
    { "y": -12, "x_min": -48, "x_max": 48 }
  ]
}

Or at the level scope inside the .vplay file, which lets you override or add areas without editing the original .vec:

{
  "objects": [
    {
      "vectorName": "platform_wide",
      "x": 0, "y": -40,
      "walkable_areas": [
        { "y": 8, "x_min": -60, "x_max": 60 }
      ]
    }
  ]
}

Transitions

Transitions tell the AI how to get from one area to another — whether that's jumping up, dropping down, or walking across:

{
  "transitions": [
    {
      "from": 0,
      "to": 1,
      "type": "jump_up",
      "takeoff_x": 40,
      "landing_x": -30
    }
  ]
}

If you don't define transitions, the compiler tries to derive them automatically based on geometry — enemies that are close enough in Y will get auto-derived jump/drop connections.

The isolateScreens flag prevents auto-jumps between objects on different screens, which is useful for multi-screen level layouts where enemies should not teleport off the edge of a platform.

Physics

AIRBORNE state uses real arc physics: the enemy launches with a computed initial velocity, then vy decreases by gravity each frame. The landing X is the exact pixel you specified — the trajectory is solved backwards from the target. Drops use a straight fall with the same gravity.


Level editor — completely rebuilt

In v0.2, the Playground was a physics sandbox where you dragged vector assets around and watched them bounce. It was good for experimenting but had no connection to the game's actual level loading system.

In v0.3, the Playground is a real level editor. It reads and writes the same .vplay files that LOAD_LEVEL loads at runtime. Every object you place is a game object.

What changed

The object layer model (background / gameplay / foreground) is unchanged. What is new:

Walkable area editing — each object in the level can have walkable areas drawn on it. A pencil button on the toolbar activates the draw-area mode; click and drag on any platform object to create a shelf. Area Y and X bounds are editable inline. An #idx label on each area matches the index used by the AI transition system.

Transition editor — draw arrows between walkable areas to define how enemies move between them. Takeoff and landing X positions are represented as drag handles on the arrow endpoints.

Inheritance — walkable areas and transitions defined on a .vec asset appear in grey on the playground as the inherited baseline. You can override them per-object by promoting to an explicit value, or leave them grey to inherit from the asset.

Scroll limit markers — drag the four screen-edge markers to set the camera scroll boundaries that GET_SCROLL_LIMIT_* returns at runtime.

Bulk clear — clear all walkable areas or all transitions on a selected object with a single button.

Persistence — the panel remembers the last .vplay you had open and your scroll position.


VectorEditor — new tools

Bezier curves

A new bezier drawing tool creates cubic Bézier curves. The .vec format now stores bezier control points alongside straight-line paths. The runtime uses a subdivision algorithm to render them on all three backends (M6809, PiTrex, RP2350).

Rotate

The rotate tool lets you select paths and rotate them around an arbitrary center. Useful for adjusting sprite orientation without redrawing.

Walkable area tool

The walkarea tool draws walkable area annotations directly on a .vec asset — the same shelves that the wander AI uses for navigation. Editing the .vec this way sets the default areas for every level that uses the asset.

RDP Simplify

The toolbar now has a Simplify button that runs Ramer–Douglas–Peucker path reduction on selected paths. A tolerance slider controls how aggressively control points are removed. Useful after auto-tracing or importing paths with too many segments.


Player 2 input — now actually works

J2_BUTTON_1() through J2_BUTTON_4() existed in v0.2 but silently returned wrong values due to incorrect RAM addresses in the compiled code. They are fixed in v0.3.

The IDE emulator also had no way to generate Player 2 input at all. v0.3 adds a second gamepad slot: if a second USB gamepad is connected to your computer, its input is routed to J2_*. Keyboard fallback keys are also mapped.

def loop():
    # Player 1
    p1x = J1_X()
    if J1_BUTTON_1():
        fire_p1()
 
    # Player 2 — now works in the emulator
    p2x = J2_X()
    if J2_BUTTON_1():
        fire_p2()

New scroll and level builtins

GET_SCROLL_LIMIT_LEFT/RIGHT/TOP/BOTTOM() — returns the camera scroll boundaries defined by the scroll-limit markers in the level editor. Use these to clamp camera movement without hardcoding level dimensions in game code:

camera_x: i16 = 0
 
def loop():
    camera_x += J1_X() / 16
    camera_x = clamp(camera_x, GET_SCROLL_LIMIT_LEFT(), GET_SCROLL_LIMIT_RIGHT())
    SET_CAMERA_X(camera_x)
    SHOW_LEVEL()

GET_LEVEL_FLOOR_Y() — returns the world Y of the floor surface in the current level. Useful for landing detection when you don't want to hardcode the floor coordinate.


User functions — up to 8 parameters

The limit on positional function parameters has been raised from 4 to 8. Parameters 5 through 8 are passed via the caller's stack rather than dedicated registers, so there is a small overhead for each extra argument, but the calling syntax is identical:

def draw_enemy(x, y, frame, mirror, intensity, scale, layer, visible):
    ...

SnowBros — example game in development

examples/SnowBros is an in-development port of the arcade game Snow Bros included in the repository. It targets all three backends (M6809, PiTrex, and the experimental RP2350). It is not a finished game — it serves as the main integration test for the enemy system, wander AI, animation, and multi-floor level features.

Current state: player movement and snowball throwing work on all three targets. The titchi, frog, and yellow_troll enemy types have AI and snow/thaw state machines. Multi-floor levels load correctly. Player death animation plays. No score or win condition yet.


Notable bug fixes

ARM Thumb2 IT block — in the RP2350 emulator, if conditions inside loops always evaluated as the same branch. The root cause was in the IT block handler: evalCond() masked the LSB of the condition code, making GT and LE indistinguishable. Fixed by using condPasses() which correctly respects the inversion bit. Effect: variable-radius circles, any loop variable used in a condition, would freeze at a constant value.

Bank switching in the emulator — multibank ROMs were silently broken in the IDE emulator. A custom read8/write8 override in jsvecxCore.ts was bypassing the bank-switch handler in vecx_full.js, so all reads went to the linear ROM image regardless of the current bank. Fixed by removing the override and letting vecx_full.js handle all ROM reads natively.

J2_BUTTON_*() — wrong RAM address and bitmask caused all Player 2 button reads to return garbage. Fixed.

M6809 nested expressions — expressions like a + (b * c) could produce wrong results when the inner expression's temporary (TMPVAL) was overwritten by the outer expression before being read. Fixed by re-ordering the emit sequence.


What's next

  • Score system and win/lose conditions for SnowBros
  • RP2350 hardware verification (first physical cartridge)
  • Vertical scrolling (SET_CAMERA_Y + walkable areas on Y axis)
  • Struct support (the parser already accepts them)
  • UVM2 format documentation