'╔══════════════════════════════════════════════════════════════════════════════╗
'║  Asteroids for Parallax Propeller 2                                          ║
'║  Converted from Atari 6502/DVG (Rev 4, 1979 Atari) to Spin2/PASM2            ║
'║  Original by Lyle Rains and Ed Logg                                          ║
'║  P2 conversion uses DEBUG scope_xy for vector display output                 ║
'║  Connect momentary buttons (active low) to input pins, or run attract mode   ║
'╚══════════════════════════════════════════════════════════════════════════════╝

CON
  _xtlfreq  = 20_000_000
  _clkfreq  = 270_000_000

  ' ════════════════════════════════════════════════════════════════════════════
  ' Original Atari Asteroids DIP Switch Settings (SW1 on game PCB, SW2 on ops)
  ' Change the DIP_* constants in the "ACTIVE CONFIGURATION" block below.
  ' ════════════════════════════════════════════════════════════════════════════

  ' ─── Coin Mode (SW1 bits 7-8) ───
  COIN_FREEPLAY       = 0                ' Free play — no coin required
  COIN_1COIN_2PLAYS   = 1                ' 1 coin → 2 plays
  COIN_1COIN_1PLAY    = 2                ' 1 coin → 1 play (factory default)
  COIN_2COINS_1PLAY   = 3                ' 2 coins → 1 play

  ' ─── Bonus Coin Adder (SW1 bits 3-5) — extra credit awarded at Nth coin ───
  BONUS_NONE          = 0                ' No bonus coins
  BONUS_2C_GIVES_1    = 1                ' Every 2 coins → 1 extra credit
  BONUS_4C_GIVES_1    = 2                ' Every 4 coins → 1 extra credit
  BONUS_4C_GIVES_2    = 3                ' Every 4 coins → 2 extra credits
  BONUS_5C_GIVES_1    = 4                ' Every 5 coins → 1 extra credit

  ' ─── Right-coin mechanism multiplier (SW1 bits 1-2) ───
  RIGHTCOIN_X1        = 1
  RIGHTCOIN_X4        = 4
  RIGHTCOIN_X5        = 5
  RIGHTCOIN_X6        = 6

  ' ─── Left-coin mechanism multiplier (SW1 bit 6) ───
  LEFTCOIN_X1         = 1
  LEFTCOIN_X2         = 2

  ' ─── Ships per game (SW2) ───
  SHIPS_3             = 3
  SHIPS_4             = 4

  ' ─── Extra-ship score threshold (SW2) ───
  EXTRA_EVERY_10000   = 10_000
  EXTRA_EVERY_15000   = 15_000
  EXTRA_SHIP_OFF      = 0                ' No extra ships awarded

  ' ─── ACTIVE CONFIGURATION — edit these to reconfigure ───
  DIP_COIN_MODE       = COIN_FREEPLAY 'COIN_1COIN_1PLAY
  DIP_BONUS           = BONUS_NONE
  DIP_RIGHT_MULT      = RIGHTCOIN_X1
  DIP_LEFT_MULT       = LEFTCOIN_X1
  DIP_SHIPS           = SHIPS_3
  DIP_EXTRA_SHIP_AT   = EXTRA_EVERY_10000

  ' ─── Input pin assignments (active low, internal pull-ups enabled) ───
  PIN_ROT_LEFT   = 0
  PIN_ROT_RIGHT  = 1
  PIN_THRUST     = 2
  PIN_FIRE       = 3
  PIN_HYPER      = 4
  PIN_START      = 5
  PIN_COIN_LEFT  = 6                     ' Left coin mechanism (active-low momentary)
  PIN_COIN_RIGHT = 7                     ' Right coin mechanism (active-low momentary)

  ' ─── Display constants ───
  SCREEN_W       = 1024                 ' Game coordinate space width
  SCREEN_H       = 1024                 ' Game coordinate space height (original 768, using 1024 for square)
  HALF_W         = SCREEN_W / 2
  HALF_H         = SCREEN_H / 2
  PPF            = 512                  ' Points Per Frame budget — must equal scope_xy samples
  DISPLAY_BOUND  = 510                  ' Max abs X/Y for on-screen points (scope_xy range is 520)
  ' ─── Frame timing ───
  TARGET_FPS     = 60
  FRAME_TICKS    = _clkfreq / TARGET_FPS

  ' ─── Object layout ───
  ' Indices 0..26 = asteroids, 27 = ship, 28 = saucer, 29..32 = ship bullets, 33..34 = saucer bullets
  MAX_AST        = 27
  IDX_SHIP       = 27
  IDX_SAUCER     = 28
  IDX_SBUL0      = 29                  ' First ship bullet
  IDX_SBUL3      = 32                  ' Last ship bullet
  IDX_RBUL0      = 33                  ' First saucer bullet
  IDX_RBUL1      = 34                  ' Last saucer bullet
  NUM_OBJ        = 35

  ' ─── Status values ───
  S_DEAD         = 0
  S_ALIVE        = 1                   ' Also used for bullets (counts down as timer)
  S_EXPLODE      = $80                 ' MSB set = exploding (lower bits = timer)

  ' ─── Asteroid size codes (stored in obj_size[]) ───
  AZ_LARGE       = 3
  AZ_MED         = 2
  AZ_SMALL       = 1

  ' ─── Physics ───
  FP_SHIFT       = 8                   ' Fixed-point fractional bits
  FP_ONE         = 1 << FP_SHIFT
  ROT_SPEED      = 5                   ' Direction units per frame (0-255 = full circle)
  THRUST_ACCEL   = 12                  ' Acceleration per frame in fixed-point sub-units
  MAX_SPEED      = 4 * FP_ONE          ' Max ship velocity
  BULLET_SPEED   = 8 * FP_ONE          ' Bullet speed
  BULLET_LIFE    = 36                  ' Bullet lifetime (frames)
  DRAG_SHIFT     = 0                   ' 0 = no drag (like original)

  ' ─── Hit box radii (in screen coordinates) ───
  HIT_LARGE      = 60
  HIT_MED        = 36
  HIT_SMALL      = 18
  HIT_SHIP       = 14
  HIT_SAUCER_L   = 24
  HIT_SAUCER_S   = 16
  HIT_BULLET     = 4

  ' ─── Draw scale multipliers for shapes ───
  SCALE_LARGE    = 7
  SCALE_MED      = 4
  SCALE_SMALL    = 2
  SCALE_SHIP     = 3
  SCALE_SAUCER_L = 4
  SCALE_SAUCER_S = 2

  ' ─── Shape stride constants (bytes per shape entry) ───
  AST_SHAPE_SIZE    = 22                ' 4 asteroid variants
  DIGIT_SHAPE_SIZE  = 18               ' digits 0-9
  LETTER_SHAPE_SIZE = 18               ' letters A-Z

  ' ─── Score values ───
  PTS_LARGE      = 20
  PTS_MED        = 50
  PTS_SMALL      = 100
  PTS_SAUCER_L   = 200
  PTS_SAUCER_S   = 1000
  EXTRA_LIFE_AT  = 10_000

  ' ─── Game state codes ───
  GS_ATTRACT     = 0
  GS_PLAYING     = 1
  GS_GAMEOVER    = 2
  GS_RESPAWN     = 3

  ' ─── Saucer timing ───
  SAUCER_SPAWN   = 400                 ' Frames between saucer spawns
  SAUCER_FIRE    = 50                  ' Frames between saucer shots
  SAUCER_SPEED   = 2 * FP_ONE

  ' ─── Explosion ───
  EXPLODE_TIME   = 30
  RESPAWN_DELAY  = 60
  GAMEOVER_TIME  = 180

  ' ─── Shape data sentinel values ───
  SH_END         = 127                 ' End of shape
  SH_PENUP       = 126                 ' Pen up (move without drawing)

VAR
  ' ─── Object arrays ───
  long  obj_status[NUM_OBJ]            ' S_DEAD / S_ALIVE / timer / S_EXPLODE+timer
  long  obj_x[NUM_OBJ]                 ' X position, 16.8 fixed point
  long  obj_y[NUM_OBJ]                 ' Y position, 16.8 fixed point
  long  obj_xv[NUM_OBJ]               ' X velocity, 16.8 fixed point
  long  obj_yv[NUM_OBJ]               ' Y velocity, 16.8 fixed point
  long  obj_dir[NUM_OBJ]              ' Direction 0-255
  long  obj_size[NUM_OBJ]             ' Asteroid size / saucer type
  long  obj_shape[NUM_OBJ]            ' Shape variant index

  ' ─── Game state ───
  long  game_state
  long  score
  long  high_score
  long  lives
  long  wave
  long  frame_count
  long  respawn_timer
  long  saucer_timer
  long  saucer_fire_tmr
  long  gameover_timer
  long  extra_life_next                ' Next score threshold for extra life
  long  active_asteroids               ' Count of live asteroids

  ' ─── Input state (1=pressed) ───
  long  in_left, in_right, in_thrust, in_fire, in_hyper, in_start
  long  fire_prev                      ' Previous fire state for edge detection
  long  coin_l_prev, coin_r_prev       ' Previous coin input state for edge detection
  long  start_prev                     ' Previous start state for edge detection

  ' ─── Coin / credit state ───
  long  credits                        ' Number of unplayed credits
  long  coins_toward_play              ' Coins accumulated toward next play credit
  long  coins_toward_bonus             ' Coins accumulated toward next bonus credit
  long  total_coins                    ' Lifetime coins inserted (bookkeeping)

  ' ─── Rendering ───
  long  points_sent                    ' Points sent this frame

  ' ─── Explosion debris ───
  long  debris_x[6]                    ' Debris line endpoint offsets
  long  debris_y[6]
  long  debris_xv[6]
  long  debris_yv[6]

PUB main() | t, i

  ' ─── Configure input pins with pull-ups ───
  ' P_HIGH_15K drives high through 15kΩ; P_LOW_FLOAT floats low → weak pull-up only.
  ' pinh() sets DIR=1 and OUT=1 so the high-drive mode is active.
  repeat i from PIN_ROT_LEFT to PIN_COIN_RIGHT
    wrpin(i, P_HIGH_15K | P_LOW_FLOAT)
    pinh(i)

  ' Coin/start inputs start as "not pressed" (pull-ups hold them high; raw=1 means idle)
  coin_l_prev := 1
  coin_r_prev := 1
  start_prev  := 0
  credits := 0
  coins_toward_play := 0
  coins_toward_bonus := 0
  total_coins := 0

  ' ─── Initialize DEBUG vector display ───
  ' SCOPE_XY grammar has no RATE option (that's SCOPE-only). Display refreshes
  ' continuously as samples arrive, so we MUST keep per-frame samples ≤ PPF or
  ' the 512-sample persistence buffer will contain a blend of consecutive frames
  ' and the display will "flip" between orientations as the blend shifts.
'  debug(`scope_xy Asteroids pos 50 50 size 250 range 520 samples 512 dotsize 2 color $000808 'vec' GREEN 8)
  debug(`plot Asteroids size 512 512 290 backcolor black update)
  debug(`Asteroids origin 0 0 )
  debug(`Asteroids LINESIZE 1 COLOR white)
  debug(`Asteroids set 0 0)




  high_score := 5000
  start_attract()

  ' ─── Main loop ───
  t := getct()
  repeat
    waitct(t += FRAME_TICKS)
    debug(`Asteroids clear)
    frame_count++
    read_inputs()

    case game_state
      GS_ATTRACT:  update_attract()
      GS_PLAYING:  update_game()
      GS_GAMEOVER: update_gameover()
      GS_RESPAWN:  update_respawn()

    render_frame()
    debug(`Asteroids update)

' ╔════════════════════════════════════════════════════════════════╗
' ║  INPUT                                                         ║
' ╚════════════════════════════════════════════════════════════════╝

PRI read_inputs() | raw_cl, raw_cr
  in_left   := pinr(PIN_ROT_LEFT)  ^ 1
  in_right  := pinr(PIN_ROT_RIGHT) ^ 1
  in_thrust := pinr(PIN_THRUST)    ^ 1
  in_fire   := pinr(PIN_FIRE)      ^ 1
  in_hyper  := pinr(PIN_HYPER)     ^ 1
  in_start  := pinr(PIN_START)     ^ 1

  ' ─── Coin inputs: edge-detect on raw (active-low) pin; register on high-to-low transition ───
  raw_cl := pinr(PIN_COIN_LEFT)
  raw_cr := pinr(PIN_COIN_RIGHT)
  if coin_l_prev == 1 AND raw_cl == 0
    handle_coin_drop(DIP_LEFT_MULT)
  if coin_r_prev == 1 AND raw_cr == 0
    handle_coin_drop(DIP_RIGHT_MULT)
  coin_l_prev := raw_cl
  coin_r_prev := raw_cr


' ╔════════════════════════════════════════════════════════════════╗
' ║  COIN / CREDIT HANDLING (Atari-style DIP-switch emulation)     ║
' ╚════════════════════════════════════════════════════════════════╝

' Called once per physical coin drop. The mechanism multiplier (1, 2, 4, 5, or 6)
' determines how many virtual coin-units this drop represents. Each unit is
' independently processed for (a) coin-mode credit conversion and (b) the bonus
' adder, matching the behavior of the original 1979 Asteroids operator manual.
PRI handle_coin_drop(mult)
  if mult < 1
    mult := 1
  repeat mult
    total_coins++
    apply_coin_mode()
    apply_coin_bonus()

PRI apply_coin_mode()
  case DIP_COIN_MODE
    COIN_FREEPLAY:
      ' Free play ignores coins — credits are meaningless, start is always allowed
    COIN_1COIN_2PLAYS:
      credits += 2
    COIN_1COIN_1PLAY:
      credits++
    COIN_2COINS_1PLAY:
      coins_toward_play++
      if coins_toward_play >= 2
        credits++
        coins_toward_play -= 2

PRI apply_coin_bonus()
  coins_toward_bonus++
  case DIP_BONUS
    BONUS_NONE:
      coins_toward_bonus := 0        ' No accumulation needed
    BONUS_2C_GIVES_1:
      if coins_toward_bonus >= 2
        credits++
        coins_toward_bonus -= 2
    BONUS_4C_GIVES_1:
      if coins_toward_bonus >= 4
        credits++
        coins_toward_bonus -= 4
    BONUS_4C_GIVES_2:
      if coins_toward_bonus >= 4
        credits += 2
        coins_toward_bonus -= 4
    BONUS_5C_GIVES_1:
      if coins_toward_bonus >= 5
        credits++
        coins_toward_bonus -= 5


' ╔════════════════════════════════════════════════════════════════╗
' ║  GAME STATE MANAGEMENT                                         ║
' ╚════════════════════════════════════════════════════════════════╝

PRI start_attract() | i
  game_state := GS_ATTRACT
  longfill(@obj_status, S_DEAD, NUM_OBJ)
  longfill(@obj_xv, 0, NUM_OBJ)
  longfill(@obj_yv, 0, NUM_OBJ)
  score := 0
  lives := 0
  active_asteroids := 0
  spawn_wave_asteroids(4)

PRI start_game()
  ' Deduct one credit (unless free-play)
  if DIP_COIN_MODE <> COIN_FREEPLAY
    if credits > 0
      credits--
  game_state := GS_PLAYING
  score := 0
  lives := DIP_SHIPS
  wave := 1
  if DIP_EXTRA_SHIP_AT == EXTRA_SHIP_OFF
    extra_life_next := POSX              ' Never reached — disables extra ships
  else
    extra_life_next := DIP_EXTRA_SHIP_AT
  longfill(@obj_status, S_DEAD, NUM_OBJ)
  longfill(@obj_xv, 0, NUM_OBJ)
  longfill(@obj_yv, 0, NUM_OBJ)
  center_ship()
  obj_status[IDX_SHIP] := S_ALIVE
  saucer_timer := SAUCER_SPAWN
  active_asteroids := 0
  spawn_wave_asteroids(wave + 3)

PRI center_ship()
  obj_x[IDX_SHIP]  := HALF_W << FP_SHIFT
  obj_y[IDX_SHIP]  := HALF_H << FP_SHIFT
  obj_xv[IDX_SHIP] := 0
  obj_yv[IDX_SHIP] := 0
  obj_dir[IDX_SHIP] := 0

PRI start_next_wave()
  wave++
  saucer_timer := SAUCER_SPAWN
  ' Kill any remaining saucer
  obj_status[IDX_SAUCER] := S_DEAD
  spawn_wave_asteroids(wave + 3)

PRI spawn_wave_asteroids(count) | i, ax, ay
  if count > 12
    count := 12
  repeat i from 0 to count - 1
    ax := (getrnd() +// SCREEN_W) << FP_SHIFT
    ay := (getrnd() +// SCREEN_H) << FP_SHIFT
    ' Keep asteroids away from center (ship spawn area)
    if ABS((ax SAR FP_SHIFT) - HALF_W) < 150 AND ABS((ay SAR FP_SHIFT) - HALF_H) < 150
      ax := 100 << FP_SHIFT
    spawn_asteroid_at(i, ax, ay, AZ_LARGE)
  active_asteroids := count


' ╔════════════════════════════════════════════════════════════════╗
' ║  ASTEROID SPAWNING                                             ║
' ╚════════════════════════════════════════════════════════════════╝

PRI spawn_asteroid_at(idx, ax, ay, asize) | spd, r
  if idx >= MAX_AST
    return
  obj_status[idx] := S_ALIVE
  obj_x[idx] := ax
  obj_y[idx] := ay
  obj_size[idx] := asize
  obj_shape[idx] := getrnd() & 3

  ' Speed inversely proportional to size
  case asize
    AZ_LARGE: spd := FP_ONE + (getrnd() & (FP_ONE - 1))
    AZ_MED:   spd := FP_ONE + FP_ONE/2 + (getrnd() & (FP_ONE - 1))
    AZ_SMALL: spd := 2 * FP_ONE + (getrnd() & (FP_ONE - 1))
    other:    spd := FP_ONE

  r := getrnd() & 255
  obj_xv[idx] := (qsin(spd, r, 256))
  obj_yv[idx] := (qcos(spd, r, 256))

PRI find_free_asteroid() : idx | i
  repeat i from 0 to MAX_AST - 1
    if obj_status[i] == S_DEAD
      return i
  return -1

PRI break_asteroid(idx) | px, py, sz, i, nidx
  px := obj_x[idx]
  py := obj_y[idx]
  sz := obj_size[idx]

  if sz == AZ_SMALL
    ' Small asteroids just die
    obj_status[idx] := S_DEAD
    active_asteroids--
    return

  ' Spawn two smaller asteroids
  sz--
  repeat i from 0 to 1
    if i == 0
      ' Reuse current slot for first child
      nidx := idx
    else
      nidx := find_free_asteroid()
      if nidx < 0
        return                         ' No free slots
    spawn_asteroid_at(nidx, px + (i * 10 - 5) << FP_SHIFT, py + (i * 10 - 5) << FP_SHIFT, sz)
  active_asteroids++                   ' Net: replaced 1 with 2 = +1


' ╔════════════════════════════════════════════════════════════════╗
' ║  UPDATE: ATTRACT MODE                                          ║
' ╚════════════════════════════════════════════════════════════════╝

PRI update_attract() | i, start_edge
  update_asteroids()
  ' Edge-detect START so holding the button after boot doesn't auto-restart
  start_edge := in_start AND (start_prev ^ 1)
  start_prev := in_start
  if start_edge
    if DIP_COIN_MODE == COIN_FREEPLAY OR credits > 0
      start_game()


' ╔════════════════════════════════════════════════════════════════╗
' ║  UPDATE: GAME PLAYING                                          ║
' ╚════════════════════════════════════════════════════════════════╝

PRI update_game()
  update_ship()
  update_bullets()
  update_asteroids()
  update_saucer()
  check_collisions()

  ' ─── Wave complete? ───
  if active_asteroids <= 0
    start_next_wave()

  ' ─── Extra life check (disabled when DIP_EXTRA_SHIP_AT == EXTRA_SHIP_OFF) ───
  if DIP_EXTRA_SHIP_AT <> EXTRA_SHIP_OFF AND score >= extra_life_next
    lives++
    extra_life_next += DIP_EXTRA_SHIP_AT

PRI update_ship() | sn, cs, fire_edge
  if obj_status[IDX_SHIP] <> S_ALIVE
    return

  ' ─── Rotation ───
  if in_left
    obj_dir[IDX_SHIP] := (obj_dir[IDX_SHIP] + ROT_SPEED) & 255
  if in_right
    obj_dir[IDX_SHIP] := (obj_dir[IDX_SHIP] - ROT_SPEED) & 255

  ' ─── Thrust ───
  if in_thrust
    sn := qsin(THRUST_ACCEL, obj_dir[IDX_SHIP], 256)
    cs := qcos(THRUST_ACCEL, obj_dir[IDX_SHIP], 256)
    obj_xv[IDX_SHIP] := clamp(obj_xv[IDX_SHIP] + sn, -MAX_SPEED, MAX_SPEED)
    obj_yv[IDX_SHIP] := clamp(obj_yv[IDX_SHIP] + cs, -MAX_SPEED, MAX_SPEED)

  ' ─── Update position & wrap ───
  move_and_wrap(IDX_SHIP)

  ' ─── Fire (edge-triggered) ───
  fire_edge := in_fire & (fire_prev ^ 1)
  fire_prev := in_fire
  if fire_edge
    fire_ship_bullet()

  ' ─── Hyperspace ───
  if in_hyper
    do_hyperspace()

PRI fire_ship_bullet() | i, sn, cs, d
  repeat i from IDX_SBUL0 to IDX_SBUL3
    if obj_status[i] == S_DEAD
      d := obj_dir[IDX_SHIP]
      sn := qsin(BULLET_SPEED, d, 256)
      cs := qcos(BULLET_SPEED, d, 256)
      obj_status[i] := BULLET_LIFE
      obj_x[i]  := obj_x[IDX_SHIP]
      obj_y[i]  := obj_y[IDX_SHIP]
      obj_xv[i] := obj_xv[IDX_SHIP] + sn
      obj_yv[i] := obj_yv[IDX_SHIP] + cs
      return

PRI do_hyperspace() | r
  r := getrnd()
  obj_x[IDX_SHIP] := (r +// SCREEN_W) << FP_SHIFT
  r := getrnd()
  obj_y[IDX_SHIP] := (r +// SCREEN_H) << FP_SHIFT
  obj_xv[IDX_SHIP] := 0
  obj_yv[IDX_SHIP] := 0
  ' 25% chance of death on re-entry (like original)
  if (getrnd() & 3) == 0
    kill_ship()

PRI update_bullets() | i
  repeat i from IDX_SBUL0 to IDX_RBUL1
    if obj_status[i] > S_DEAD AND obj_status[i] < S_EXPLODE
      obj_status[i]--
      if obj_status[i] == S_DEAD
        next
      move_and_wrap(i)

PRI update_asteroids() | i
  repeat i from 0 to MAX_AST - 1
    if obj_status[i] == S_ALIVE
      move_and_wrap(i)
    elseif obj_status[i] >= S_EXPLODE
      obj_status[i]--
      if obj_status[i] < S_EXPLODE
        obj_status[i] := S_DEAD


' ╔════════════════════════════════════════════════════════════════╗
' ║  UPDATE: SAUCER                                                ║
' ╚════════════════════════════════════════════════════════════════╝

PRI update_saucer() | d, sn, cs, r
  if game_state <> GS_PLAYING
    return

  ' ─── Handle explosion ───
  if obj_status[IDX_SAUCER] >= S_EXPLODE
    obj_status[IDX_SAUCER]--
    if obj_status[IDX_SAUCER] < S_EXPLODE
      obj_status[IDX_SAUCER] := S_DEAD
    return

  ' ─── Spawn timer ───
  if obj_status[IDX_SAUCER] == S_DEAD
    saucer_timer--
    if saucer_timer <= 0
      spawn_saucer()
    return

  ' ─── Move saucer ───
  move_and_wrap(IDX_SAUCER)

  ' ─── Check if saucer left screen (horizontal exit) ───
  r := obj_x[IDX_SAUCER] SAR FP_SHIFT
  if r < -20 OR r > SCREEN_W + 20
    obj_status[IDX_SAUCER] := S_DEAD
    saucer_timer := SAUCER_SPAWN / 2

  ' ─── Randomly change Y velocity ───
  if (frame_count & 63) == 0
    r := getrnd() & 3
    case r
      0: obj_yv[IDX_SAUCER] := -FP_ONE * 2
      1: obj_yv[IDX_SAUCER] := 0
      2: obj_yv[IDX_SAUCER] := 0
      3: obj_yv[IDX_SAUCER] := FP_ONE * 2

  ' ─── Fire at player ───
  saucer_fire_tmr--
  if saucer_fire_tmr <= 0
    saucer_fire_tmr := SAUCER_FIRE
    fire_saucer_bullet()

PRI spawn_saucer() | r
  obj_status[IDX_SAUCER] := S_ALIVE
  saucer_fire_tmr := SAUCER_FIRE

  ' Decide large vs small based on score
  if score > 3000
    obj_size[IDX_SAUCER] := 1          ' Small saucer
  else
    obj_size[IDX_SAUCER] := 2          ' Large saucer

  ' Enter from left or right
  r := getrnd()
  if r & 1
    obj_x[IDX_SAUCER] := 0
    obj_xv[IDX_SAUCER] := SAUCER_SPEED
  else
    obj_x[IDX_SAUCER] := SCREEN_W << FP_SHIFT
    obj_xv[IDX_SAUCER] := -SAUCER_SPEED

  obj_y[IDX_SAUCER] := ((r +// (SCREEN_H - 200)) + 100) << FP_SHIFT
  obj_yv[IDX_SAUCER] := 0

PRI fire_saucer_bullet() | i, d, sn, cs, dx, dy
  repeat i from IDX_RBUL0 to IDX_RBUL1
    if obj_status[i] == S_DEAD
      if obj_size[IDX_SAUCER] == 1 AND obj_status[IDX_SHIP] == S_ALIVE
        ' Small saucer: aimed shot with some inaccuracy
        dx := (obj_x[IDX_SHIP] - obj_x[IDX_SAUCER]) SAR FP_SHIFT
        dy := (obj_y[IDX_SHIP] - obj_y[IDX_SAUCER]) SAR FP_SHIFT
        d := atan2_approx(dx, dy)
        d := (d + (getrnd() +// 30) - 15) & 255
      else
        ' Large saucer: random direction
        d := getrnd() & 255

      sn := qsin(BULLET_SPEED, d, 256)
      cs := qcos(BULLET_SPEED, d, 256)
      obj_status[i] := BULLET_LIFE
      obj_x[i]  := obj_x[IDX_SAUCER]
      obj_y[i]  := obj_y[IDX_SAUCER]
      obj_xv[i] := sn
      obj_yv[i] := cs
      return


' ╔════════════════════════════════════════════════════════════════╗
' ║  COLLISION DETECTION                                           ║
' ╚════════════════════════════════════════════════════════════════╝

PRI check_collisions() | i, j, hr
  ' ─── Ship bullets vs asteroids ───
  repeat i from IDX_SBUL0 to IDX_SBUL3
    if obj_status[i] > S_DEAD AND obj_status[i] < S_EXPLODE
      repeat j from 0 to MAX_AST - 1
        if obj_status[j] == S_ALIVE
          hr := get_hit_radius_ast(j) + HIT_BULLET
          if objects_collide(i, j, hr)
            obj_status[i] := S_DEAD
            add_asteroid_score(j)
            start_explosion(j)
            break_asteroid(j)
            quit

  ' ─── Ship bullets vs saucer ───
  repeat i from IDX_SBUL0 to IDX_SBUL3
    if obj_status[i] > S_DEAD AND obj_status[i] < S_EXPLODE
      if obj_status[IDX_SAUCER] == S_ALIVE
        hr := get_hit_radius_saucer() + HIT_BULLET
        if objects_collide(i, IDX_SAUCER, hr)
          obj_status[i] := S_DEAD
          add_saucer_score()
          start_explosion(IDX_SAUCER)
          saucer_timer := SAUCER_SPAWN

  ' ─── Ship vs asteroids ───
  if obj_status[IDX_SHIP] == S_ALIVE
    repeat j from 0 to MAX_AST - 1
      if obj_status[j] == S_ALIVE
        hr := get_hit_radius_ast(j) + HIT_SHIP
        if objects_collide(IDX_SHIP, j, hr)
          kill_ship()
          start_explosion(j)
          break_asteroid(j)
          quit

  ' ─── Ship vs saucer ───
  if obj_status[IDX_SHIP] == S_ALIVE AND obj_status[IDX_SAUCER] == S_ALIVE
    hr := get_hit_radius_saucer() + HIT_SHIP
    if objects_collide(IDX_SHIP, IDX_SAUCER, hr)
      kill_ship()
      start_explosion(IDX_SAUCER)
      saucer_timer := SAUCER_SPAWN

  ' ─── Saucer bullets vs ship ───
  if obj_status[IDX_SHIP] == S_ALIVE
    repeat i from IDX_RBUL0 to IDX_RBUL1
      if obj_status[i] > S_DEAD AND obj_status[i] < S_EXPLODE
        if objects_collide(i, IDX_SHIP, HIT_SHIP + HIT_BULLET)
          obj_status[i] := S_DEAD
          kill_ship()
          quit

  ' ─── Saucer bullets vs asteroids ───
  repeat i from IDX_RBUL0 to IDX_RBUL1
    if obj_status[i] > S_DEAD AND obj_status[i] < S_EXPLODE
      repeat j from 0 to MAX_AST - 1
        if obj_status[j] == S_ALIVE
          hr := get_hit_radius_ast(j) + HIT_BULLET
          if objects_collide(i, j, hr)
            obj_status[i] := S_DEAD
            start_explosion(j)
            break_asteroid(j)
            quit

PRI objects_collide(a, b, radius) : hit | dx, dy
  dx := ABS(obj_x[a] - obj_x[b]) SAR FP_SHIFT
  dy := ABS(obj_y[a] - obj_y[b]) SAR FP_SHIFT
  ' Quick reject
  if dx > radius OR dy > radius
    return false
  ' Approximate circular hit: |dx| + |dy| < radius * 1.5 (Manhattan with correction like original)
  hit := (dx + dy) < (radius + radius / 2)

PRI get_hit_radius_ast(idx) : r
  case obj_size[idx]
    AZ_LARGE: r := HIT_LARGE
    AZ_MED:   r := HIT_MED
    AZ_SMALL: r := HIT_SMALL
    other:    r := HIT_SMALL

PRI get_hit_radius_saucer() : r
  if obj_size[IDX_SAUCER] == 1
    r := HIT_SAUCER_S
  else
    r := HIT_SAUCER_L

PRI kill_ship()
  start_ship_explosion()
  lives--
  if lives <= 0
    game_state := GS_GAMEOVER
    gameover_timer := GAMEOVER_TIME
  else
    game_state := GS_RESPAWN
    respawn_timer := RESPAWN_DELAY

PRI start_explosion(idx)
  obj_status[idx] := S_EXPLODE + EXPLODE_TIME
  obj_xv[idx] := 0
  obj_yv[idx] := 0

PRI start_ship_explosion() | i, r
  obj_status[IDX_SHIP] := S_EXPLODE + EXPLODE_TIME
  obj_xv[IDX_SHIP] := 0
  obj_yv[IDX_SHIP] := 0
  ' Generate random debris velocities
  repeat i from 0 to 5
    r := getrnd()
    debris_x[i]  := (r signx 5)          ' Initial radial offset ±16
    debris_y[i]  := ((r >> 8) signx 5)
    debris_xv[i] := ((r >> 16) signx 3)  ' Velocity ±4 per frame
    debris_yv[i] := ((r >> 24) signx 3)

PRI add_asteroid_score(idx)
  case obj_size[idx]
    AZ_LARGE: score += PTS_LARGE
    AZ_MED:   score += PTS_MED
    AZ_SMALL: score += PTS_SMALL
  if score > high_score
    high_score := score

PRI add_saucer_score()
  if obj_size[IDX_SAUCER] == 1
    score += PTS_SAUCER_S
  else
    score += PTS_SAUCER_L
  if score > high_score
    high_score := score


' ╔════════════════════════════════════════════════════════════════╗
' ║  UPDATE: GAME OVER / RESPAWN                                   ║
' ╚════════════════════════════════════════════════════════════════╝

PRI update_gameover()
  update_asteroids()
  ' Let explosions finish
  if obj_status[IDX_SHIP] >= S_EXPLODE
    obj_status[IDX_SHIP]--
    if obj_status[IDX_SHIP] < S_EXPLODE
      obj_status[IDX_SHIP] := S_DEAD
  gameover_timer--
  if gameover_timer <= 0
    start_attract()

PRI update_respawn()
  update_asteroids()
  update_bullets()
  update_saucer()
  ' Let explosion finish
  if obj_status[IDX_SHIP] >= S_EXPLODE
    obj_status[IDX_SHIP]--
    if obj_status[IDX_SHIP] < S_EXPLODE
      obj_status[IDX_SHIP] := S_DEAD
  respawn_timer--
  if respawn_timer <= 0
    center_ship()
    obj_status[IDX_SHIP] := S_ALIVE
    fire_prev := 0
    game_state := GS_PLAYING


' ╔════════════════════════════════════════════════════════════════╗
' ║  PHYSICS HELPERS                                               ║
' ╚════════════════════════════════════════════════════════════════╝

PRI move_and_wrap(idx) | x, y
  obj_x[idx] += obj_xv[idx]
  obj_y[idx] += obj_yv[idx]
  ' ─── Screen wrap ───
  x := obj_x[idx] SAR FP_SHIFT
  y := obj_y[idx] SAR FP_SHIFT
  if x < -80
    obj_x[idx] += (SCREEN_W + 160) << FP_SHIFT
  elseif x > SCREEN_W + 80
    obj_x[idx] -= (SCREEN_W + 160) << FP_SHIFT
  if y < -80
    obj_y[idx] += (SCREEN_H + 160) << FP_SHIFT
  elseif y > SCREEN_H + 80
    obj_y[idx] -= (SCREEN_H + 160) << FP_SHIFT

PRI clamp(val, lo, hi) : r
  r := val
  if r < lo
    r := lo
  elseif r > hi
    r := hi

PRI atan2_approx(dx, dy) : angle | adx, ady, t
  ' Quick and dirty atan2 returning 0-255
  adx := ABS(dx)
  ady := ABS(dy)
  if adx == 0 AND ady == 0
    return 0
  if adx > ady
    t := (ady * 32) / adx              ' 0..32 for 0..45 degrees
  else
    t := 64 - (adx * 32) / ady         ' 32..64 for 45..90 degrees
  ' Map to correct quadrant (0=up, 64=right, 128=down, 192=left)
  if dx >= 0
    if dy >= 0
      angle := t                       ' Q1: 0..63
    else
      angle := 128 - t                 ' Q4: 64..128
  else
    if dy >= 0
      angle := 256 - t                 ' Q2: 192..255
    else
      angle := 128 + t                 ' Q3: 128..192
  angle &= 255


' ╔════════════════════════════════════════════════════════════════╗
' ║  RENDERING ENGINE                                              ║
' ╚════════════════════════════════════════════════════════════════╝

PRI render_frame() | i
  points_sent := 0

  ' ─── Draw asteroids ───
  repeat i from 0 to MAX_AST - 1
    if obj_status[i] == S_ALIVE
      draw_asteroid(i)
    elseif obj_status[i] >= S_EXPLODE
      draw_generic_explosion(i)

  ' ─── Draw ship ───
  if game_state <> GS_ATTRACT
    if obj_status[IDX_SHIP] == S_ALIVE
      draw_ship()
    elseif obj_status[IDX_SHIP] >= S_EXPLODE
      draw_ship_explosion()

  ' ─── Draw saucer ───
  if obj_status[IDX_SAUCER] == S_ALIVE
    draw_saucer()
  elseif obj_status[IDX_SAUCER] >= S_EXPLODE
    draw_generic_explosion(IDX_SAUCER)

  ' ─── Draw bullets ───
  repeat i from IDX_SBUL0 to IDX_RBUL1
    if obj_status[i] > S_DEAD AND obj_status[i] < S_EXPLODE
      draw_bullet(i)

  ' ─── Draw HUD ───
  draw_score()
  if game_state == GS_PLAYING OR game_state == GS_RESPAWN
    draw_lives()

  ' ─── Draw text overlays ─────────────────────────────────────────────────
  ' NOTE: text is expensive (~20 points per character). Keep each frame's total
  ' visible text to ≤10 characters to stay under budget with 4 asteroids.
  ' Cycle through messages on a slow blink so only one text line is active at
  ' any given frame — this keeps the per-frame sample count well under PPF and
  ' prevents the 512-sample persistence buffer from blending neighboring frames.
  case game_state
    GS_ATTRACT:
      ' 64-frame cycle: 0-31 show "PUSH START" or "INSERT COINS", 32-63 show mode
      if (frame_count & 63) < 32
        if DIP_COIN_MODE == COIN_FREEPLAY OR credits > 0
          draw_text_at(string("PUSH START"), HALF_W - 80, HALF_H - 20)
        else
          draw_text_at(string("INSERT COINS"), HALF_W - 95, HALF_H - 20)
      else
        draw_coin_mode_msg()
    GS_GAMEOVER:
      draw_text_at(string("GAME OVER"), HALF_W - 75, HALF_H)

  ' ─── Per-frame sample-budget telemetry (every ~4 sec) ───
  if (frame_count // 80) == 0
    debug("PTS=", udec_(points_sent), " STATE=", udec_(game_state), " CREDITS=", udec_(credits))




' ╔════════════════════════════════════════════════════════════════╗
' ║  SHAPE DRAWING                                                 ║
' ╚════════════════════════════════════════════════════════════════╝

PRI draw_ship() | cx, cy, d, i, px, py, nx, ny, sc, shp
  cx := obj_x[IDX_SHIP] SAR FP_SHIFT
  cy := obj_y[IDX_SHIP] SAR FP_SHIFT
  d  := obj_dir[IDX_SHIP]
  sc := SCALE_SHIP
  shp := @ship_shape

  draw_transformed_shape(shp, cx, cy, d, sc)

  ' ─── Draw thrust flame when thrusting ───
  if in_thrust AND (frame_count & 2)
    draw_transformed_shape(@thrust_shape, cx, cy, d, sc)

PRI draw_asteroid(idx) | cx, cy, sc, shp
  cx := obj_x[idx] SAR FP_SHIFT
  cy := obj_y[idx] SAR FP_SHIFT
  case obj_size[idx]
    AZ_LARGE: sc := SCALE_LARGE
    AZ_MED:   sc := SCALE_MED
    AZ_SMALL: sc := SCALE_SMALL
    other:    sc := SCALE_SMALL
  shp := @asteroid_shapes + (obj_shape[idx] & 3) * AST_SHAPE_SIZE
  draw_transformed_shape(shp, cx, cy, 0, sc)

PRI draw_saucer() | cx, cy, sc
  cx := obj_x[IDX_SAUCER] SAR FP_SHIFT
  cy := obj_y[IDX_SAUCER] SAR FP_SHIFT
  if obj_size[IDX_SAUCER] == 1
    sc := SCALE_SAUCER_S
  else
    sc := SCALE_SAUCER_L
  draw_transformed_shape(@saucer_shape, cx, cy, 0, sc)

PRI draw_bullet(idx) | bx, by
  bx := obj_x[idx] SAR FP_SHIFT
  by := obj_y[idx] SAR FP_SHIFT
  send_point(bx - HALF_W, by - HALF_H)
  send_point(bx - HALF_W + 1, by - HALF_H)

PRI draw_generic_explosion(idx) | cx, cy, i, r, ex, ey, t
  cx := obj_x[idx] SAR FP_SHIFT
  cy := obj_y[idx] SAR FP_SHIFT
  t  := obj_status[idx] - S_EXPLODE
  ' Draw random expanding fragments
  repeat i from 0 to 4
    r := getrnd()
    ex := (r signx 6) * (EXPLODE_TIME - t) / 3
    ey := ((r >> 8) signx 6) * (EXPLODE_TIME - t) / 3
    draw_line_screen(cx + ex/2, cy + ey/2, cx + ex, cy + ey)

PRI draw_ship_explosion() | cx, cy, i, ex, ey
  cx := obj_x[IDX_SHIP] SAR FP_SHIFT
  cy := obj_y[IDX_SHIP] SAR FP_SHIFT
  repeat i from 0 to 5
    debris_x[i] += debris_xv[i]
    debris_y[i] += debris_yv[i]
    ex := debris_x[i]
    ey := debris_y[i]
    draw_line_screen(cx + ex - debris_xv[i] * 3, cy + ey - debris_yv[i] * 3, cx + ex, cy + ey)


' ╔════════════════════════════════════════════════════════════════╗
' ║  TRANSFORMED SHAPE DRAWING                                     ║
' ╚════════════════════════════════════════════════════════════════╝

PRI draw_transformed_shape(shp_ptr, cx, cy, dir, scale) | i, vx, vy, rx, ry, px, py, pen, sx, sy, cs, sn, first
  ' Pre-compute rotation
  if dir
    sn := qsin(256, dir, 256)
    cs := qcos(256, dir, 256)
  else
    sn := 0
    cs := 256

  pen := 0                            ' 0 = pen up (move), 1 = pen down (draw)
  first := 1
  px := cx
  py := cy
  i := 0
  repeat
    vx := byte[shp_ptr][i++] signx 7
    if vx == SH_END
      quit
    if vx == SH_PENUP
      pen := 0
      i++                              ' Skip paired Y byte
      next
    vy := byte[shp_ptr][i++] signx 7

    ' Apply scale
    sx := vx * scale
    sy := vy * scale

    ' Apply rotation
    rx := (sx * cs - sy * sn) / 256
    ry := (sx * sn + sy * cs) / 256

    ' Translate to screen
    rx += cx
    ry += cy

    if pen AND NOT first
      draw_line_screen(px, py, rx, ry)
    pen := 1
    first := 0
    px := rx
    py := ry


' ╔════════════════════════════════════════════════════════════════╗
' ║  LINE DRAWING (interpolated points for scope_xy)               ║
' ╚════════════════════════════════════════════════════════════════╝

PRI draw_line_screen(x1, y1, x2, y2)
  x1 -= HALF_W
  y1 -= HALF_H
  x2 -= HALF_W
  y2 -= HALF_H
  if x1 < -DISPLAY_BOUND OR x1 > DISPLAY_BOUND OR y1 < -DISPLAY_BOUND OR y1 > DISPLAY_BOUND
    return
  if x2 < -DISPLAY_BOUND OR x2 > DISPLAY_BOUND OR y2 < -DISPLAY_BOUND OR y2 > DISPLAY_BOUND
    return
  if points_sent + 1 > PPF
    return
  debug(`Asteroids set `((x1+512)/2) `((y1+512)/2))
  debug(`Asteroids line `((x2+512)/2) `((y2+512)/2))
  points_sent++

PRI send_point(x, y)
  if points_sent >= PPF
    return
  if x < -DISPLAY_BOUND OR x > DISPLAY_BOUND OR y < -DISPLAY_BOUND OR y > DISPLAY_BOUND
    return
  debug(`Asteroids set `((x+512)/2) `((y+512)/2))
  debug(`Asteroids line `((x+512)/2) `((y+512)/2))
  points_sent++


' ╔════════════════════════════════════════════════════════════════╗
' ║  HUD: SCORE & LIVES                                           ║
' ╚════════════════════════════════════════════════════════════════╝

PRI draw_score() | s, d, x, i, digits, show_score
  ' Display score (or high score in attract)
  if game_state == GS_ATTRACT
    show_score := high_score
  else
    show_score := score

  ' Extract digits (up to 6 digits)
  x := -HALF_W + 20
  s := show_score
  if s == 0
    draw_digit(0, x, -HALF_H + 30)
    return
  ' Count digits
  digits := 0
  repeat while s > 0
    digits++
    s /= 10
  s := show_score
  ' Draw from left
  x += (digits - 1) * 14
  repeat i from 0 to digits - 1
    d := s +// 10
    s /= 10
    draw_digit(d, x, -HALF_H + 30)
    x -= 14

PRI draw_lives() | i, x
  x := -HALF_W + 20
  repeat i from 0 to (lives - 1) <# 7
    draw_transformed_shape(@ship_shape, x + HALF_W, 950, 0, 2)
    x += 20

PRI draw_digit(digit, x, y) | shp, i, vx, vy, px, py, pen
  shp := @digit_shapes + digit * DIGIT_SHAPE_SIZE
  pen := 0
  px := x
  py := y
  i := 0
  repeat
    vx := byte[shp][i++] signx 7
    if vx == SH_END
      quit
    if vx == SH_PENUP
      pen := 0
      i++
      next
    vy := byte[shp][i++] signx 7
    vx := vx * 2 + x
    vy := vy * 2 + y
    if pen
      draw_line_display(px, py, vx, vy)
    pen := 1
    px := vx
    py := vy

PRI draw_line_display(x1, y1, x2, y2)
  if x1 < -DISPLAY_BOUND OR x1 > DISPLAY_BOUND OR y1 < -DISPLAY_BOUND OR y1 > DISPLAY_BOUND
    return
  if x2 < -DISPLAY_BOUND OR x2 > DISPLAY_BOUND OR y2 < -DISPLAY_BOUND OR y2 > DISPLAY_BOUND
    return
  if points_sent + 1 > PPF
    return
  debug(`Asteroids set `((x1+512)/2) `((y1+512)/2))
  debug(`Asteroids line `((x2+512)/2) `((y2+512)/2))
  points_sent++


' ╔════════════════════════════════════════════════════════════════╗
' ║  VECTOR TEXT DRAWING                                           ║
' ╚════════════════════════════════════════════════════════════════╝

PRI draw_coin_mode_msg()
  case DIP_COIN_MODE
    COIN_FREEPLAY:
      draw_text_at(string("FREE PLAY"), HALF_W - 75, HALF_H + 170)
    COIN_1COIN_2PLAYS:
      draw_text_at(string("1 COIN 2 PLAYS"), HALF_W - 110, HALF_H + 170)
    COIN_1COIN_1PLAY:
      draw_text_at(string("1 COIN 1 PLAY"), HALF_W - 105, HALF_H + 170)
    COIN_2COINS_1PLAY:
      draw_text_at(string("2 COINS 1 PLAY"), HALF_W - 110, HALF_H + 170)

PRI draw_credits_readout() | c
  if DIP_COIN_MODE == COIN_FREEPLAY
    return                               ' No credit readout in free-play
  ' draw_text_at takes screen-space (0..SCREEN_W); draw_digit takes display-space.
  draw_text_at(string("CREDITS"), HALF_W - 85, HALF_H + 210)
  c := credits <# 99                     ' Cap display at 99
  if c >= 10
    draw_digit(c / 10, 40, 210)
    draw_digit(c +// 10, 54, 210)
  else
    draw_digit(c, 40, 210)

PRI draw_text_at(str_ptr, x, y) | c, cx
  cx := x
  repeat
    c := byte[str_ptr++]
    if c == 0
      quit
    if c == " "
      cx += 12
      next
    draw_char(c, cx - HALF_W, y - HALF_H)
    cx += 16

PRI draw_char(c, x, y) | shp, i, vx, vy, px, py, pen, idx
  ' Map character to shape data
  if c >= "A" AND c <= "Z"
    idx := c - "A"
  elseif c >= "0" AND c <= "9"
    draw_digit(c - "0", x, y)
    return
  else
    return                             ' Unknown character, skip

  shp := @letter_shapes + idx * LETTER_SHAPE_SIZE
  pen := 0
  px := x
  py := y
  i := 0
  repeat
    vx := byte[shp][i++] signx 7
    if vx == SH_END
      quit
    if vx == SH_PENUP
      pen := 0
      i++
      next
    vy := byte[shp][i++] signx 7
    vx := vx * 2 + x
    vy := vy * 2 + y
    if pen
      draw_line_display(px, py, vx, vy)
    pen := 1
    px := vx
    py := vy


' ╔════════════════════════════════════════════════════════════════════════╗
' ║  SHAPE DATA                                                            ║
' ║  Format: pairs of signed bytes (x, y). SH_PENUP=lift, SH_END=done.     ║
' ╚════════════════════════════════════════════════════════════════════════╝

DAT
        orgh

' ─── Ship (pointing up at dir=0) ───
ship_shape
        byte    0, 8,  4, -5,  1, -3,  -1, -3,  -4, -5,  0, 8, SH_END, 0

thrust_shape
        byte    -1, -4, 0, -8, 1, -4, SH_END, 0

' ─── Asteroid shapes (4 variants) ───
asteroid_shapes
' Variant 0
        byte    -3, 9,  5, 7,  9, 2,  7, -5,  9, -7,  3, -9,  -4, -7,  -9, -2,  -7, 5,  -3, 9,  SH_END, 0
' Variant 1
        byte    2, 8,  8, 5,  6, 1,  8, -4,  4, -8,  -3, -9,  -8, -4,  -9, 1,  -5, 6,  2, 8,  SH_END, 0
' Variant 2
        byte    -1, 9,  6, 6,  9, 1,  4, -3,  7, -8,  1, -9,  -6, -5,  -9, -1,  -3, 4,  -1, 9,  SH_END, 0
' Variant 3
        byte    3, 8,  7, 4,  9, -1,  4, -5,  1, -9,  -5, -7,  -2, -4,  -9, -2,  -6, 4,  3, 8,  SH_END, 0

' ─── Saucer shape ───
saucer_shape
        byte    -2, 3,  2, 3,  4, 1,  4, -1,  2, -3,  -2, -3,  -4, -1,  -4, 1,  -2, 3
        byte    SH_PENUP, 0,  -4, 1,  4, 1
        byte    SH_PENUP, 0,  -4, -1,  4, -1
        byte    SH_END, 0

' ─── Digit shapes (0-9) ───
digit_shapes
' 0
        byte    -2, 4,  2, 4,  2, -4,  -2, -4,  -2, 4,  SH_END, 0, 0, 0, 0, 0, 0, 0
' 1
        byte    0, 4,  0, -4,  SH_END, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
' 2
        byte    -2, 4,  2, 4,  2, 0,  -2, 0,  -2, -4,  2, -4,  SH_END, 0, 0, 0, 0, 0
' 3
        byte    -2, 4,  2, 4,  2, -4,  -2, -4,  SH_PENUP, 0,  -2, 0,  2, 0,  SH_END, 0, 0, 0
' 4
        byte    -2, 4,  -2, 0,  2, 0,  SH_PENUP, 0,  2, 4,  2, -4,  SH_END, 0, 0, 0, 0, 0
' 5
        byte    2, 4,  -2, 4,  -2, 0,  2, 0,  2, -4,  -2, -4,  SH_END, 0, 0, 0, 0, 0
' 6
        byte    2, 4,  -2, 4,  -2, -4,  2, -4,  2, 0,  -2, 0,  SH_END, 0, 0, 0, 0, 0
' 7
        byte    -2, 4,  2, 4,  2, -4,  SH_END, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
' 8
        byte    -2, 0,  -2, 4,  2, 4,  2, -4,  -2, -4,  -2, 0,  2, 0,  SH_END, 0, 0, 0
' 9
        byte    2, 0,  -2, 0,  -2, 4,  2, 4,  2, -4,  -2, -4,  SH_END, 0, 0, 0, 0, 0

' ─── Letter shapes (A-Z) ───
letter_shapes
' A
        byte    -2, -4,  0, 4,  2, -4,  SH_PENUP, 0,  -1, 0,  1, 0,  SH_END, 0, 0, 0, 0, 0
' B
        byte    -2, -4,  -2, 4,  2, 4,  2, 0,  -2, 0,  2, 0,  2, -4,  -2, -4,  SH_END, 0
' C
        byte    2, 4,  -2, 4,  -2, -4,  2, -4,  SH_END, 0, 0, 0, 0, 0, 0, 0, 0, 0
' D
        byte    -2, -4,  -2, 4,  1, 4,  2, 2,  2, -2,  1, -4,  -2, -4,  SH_END, 0, 0, 0
' E
        byte    2, 4,  -2, 4,  -2, -4,  2, -4,  SH_PENUP, 0,  -2, 0,  1, 0,  SH_END, 0, 0, 0
' F
        byte    2, 4,  -2, 4,  -2, -4,  SH_PENUP, 0,  -2, 0,  1, 0,  SH_END, 0, 0, 0, 0, 0
' G
        byte    2, 4,  -2, 4,  -2, -4,  2, -4,  2, 0,  0, 0,  SH_END, 0, 0, 0, 0, 0
' H
        byte    -2, 4,  -2, -4,  SH_PENUP, 0,  -2, 0,  2, 0,  SH_PENUP, 0,  2, 4,  2, -4, SH_END, 0
' I
        byte    -1, 4,  1, 4,  SH_PENUP, 0,  0, 4,  0, -4,  SH_PENUP, 0,  -1, -4,  1, -4, SH_END, 0
' J
        byte    2, 4,  2, -4,  -2, -4,  -2, -1,  SH_END, 0, 0, 0, 0, 0, 0, 0, 0, 0
' K
        byte    -2, 4,  -2, -4,  SH_PENUP, 0,  2, 4,  -2, 0,  2, -4,  SH_END, 0, 0, 0, 0, 0
' L
        byte    -2, 4,  -2, -4,  2, -4,  SH_END, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
' M
        byte    -2, -4,  -2, 4,  0, 1,  2, 4,  2, -4,  SH_END, 0, 0, 0, 0, 0, 0, 0
' N
        byte    -2, -4,  -2, 4,  2, -4,  2, 4,  SH_END, 0, 0, 0, 0, 0, 0, 0, 0, 0
' O
        byte    -2, 4,  2, 4,  2, -4,  -2, -4,  -2, 4,  SH_END, 0, 0, 0, 0, 0, 0, 0
' P
        byte    -2, -4,  -2, 4,  2, 4,  2, 0,  -2, 0,  SH_END, 0, 0, 0, 0, 0, 0, 0
' Q
        byte    -2, 4,  2, 4,  2, -2,  -2, -4,  -2, 4,  SH_PENUP, 0,  1, -2,  3, -5,  SH_END, 0
' R
        byte    -2, -4,  -2, 4,  2, 4,  2, 0,  -2, 0,  2, -4,  SH_END, 0, 0, 0, 0, 0
' S
        byte    2, 4,  -2, 4,  -2, 0,  2, 0,  2, -4,  -2, -4,  SH_END, 0, 0, 0, 0, 0
' T
        byte    -2, 4,  2, 4,  SH_PENUP, 0,  0, 4,  0, -4,  SH_END, 0, 0, 0, 0, 0, 0, 0
' U
        byte    -2, 4,  -2, -4,  2, -4,  2, 4,  SH_END, 0, 0, 0, 0, 0, 0, 0, 0, 0
' V
        byte    -2, 4,  0, -4,  2, 4,  SH_END, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
' W
        byte    -2, 4,  -1, -4,  0, 1,  1, -4,  2, 4,  SH_END, 0, 0, 0, 0, 0, 0, 0
' X
        byte    -2, 4,  2, -4,  SH_PENUP, 0,  2, 4,  -2, -4,  SH_END, 0, 0, 0, 0, 0, 0, 0
' Y
        byte    -2, 4,  0, 0,  2, 4,  SH_PENUP, 0,  0, 0,  0, -4,  SH_END, 0, 0, 0, 0, 0
' Z
        byte    -2, 4,  2, 4,  -2, -4,  2, -4,  SH_END, 0, 0, 0, 0, 0, 0, 0, 0, 0
