Skip to content

VBScript Patterns & Techniques

Core scripting patterns for VPW table development. This guide covers game logic, timer architecture, ROM integration, ball management, sound, and mechanical implementations. For GI fading, flasher dome scripting, and lightmap control, see the GI and Flashers guide.

State Machines

FlexMode Pattern

FlexDMD scenes should be initialized once into an array during Flex_Init and displayed using a ShowScene wrapper routine. Recreating scenes each time they're shown throws errors. The FlexMode variable acts as a state machine: 1 = gameplay, 2 = intro/attract, etc.

Const UseFlexDMD = 1

Sub Flex_Init()
    If UseFlexDMD = 0 Then Exit Sub
    ' Initialize all scenes once
    Set Scenes(1) = CreateGameplayScene()
    Set Scenes(2) = CreateIntroScene()
End Sub

Don't put control logic inside the FlexDMD timer -- the timer should only update visual state based on variables set elsewhere (learned from VPW Example Table).

Auto-detect FlexDMD installation by wrapping the CreateObject call in error handling rather than checking filesystem paths. Filesystem checks fail on standalone (Android/Linux/Mac) and non-default install locations. FlexDMD is built into standalone VPX, so it's always available there (learned from VPW Example Table).

Mode State Tracking for Original Tables

For original (non-ROM) tables supporting multiple players, all game state variables must be explicitly saved at ball drain and restored when the next player's turn begins:

Sub SavePlayerState(playerNum)
    PlayerScore(playerNum) = Score
    PlayerModeProgress(playerNum) = ModeProgress
    PlayerLockedBalls(playerNum) = LockedBalls
    ' ... all per-player variables
End Sub

Sub RestorePlayerState(playerNum)
    Score = PlayerScore(playerNum)
    ModeProgress = PlayerModeProgress(playerNum)
    LockedBalls = PlayerLockedBalls(playerNum)
End Sub

Key pitfall: counters like ramphitsthisball may not reset between games if certain game-end paths skip the normal reset logic (learned from Goonies).

Timer Management

Timer Hierarchy

VPW tables use a three-tier timer architecture:

GameTimer (10ms interval) -- Physics-critical updates. Must contain Cor.Update or the cor.BallVel out-of-range error will occur. The timer object must exist on the table. This is the fastest timer needed for physics (learned from nFozzy Physics Guide).

FrameTimer (per-frame, interval = -1) -- Visual updates tied to rendering. DoDTAnim (drop target animation) and DoSTAnim (standup target animation) can safely run here since they already use the tween method. Ball shadow updates, ball rolling sounds, and insert lighting responses belong here (learned from Physics Debate).

LampTimer (fixed interval, typically 6-16ms) -- Light fading and Lampz updates. Must be a fixed interval, not tied to frame rate. Running Lampz fading at frame rate (-1 interval) causes different fading speeds at different FPS (learned from VPW Example Table). Must be enabled for the Light Controller to work -- the most basic requirement (learned from Light State Controller).

Timer Pitfalls

Interval = 1 is extremely wasteful. Timer interval of 1 means the timer fires every 1ms. Changing all timers from interval 1 to interval -1 (per-frame, approximately 16ms at 60Hz) doubled FPS from 80 to 150+ on Goldeneye (learned from Goldeneye). Mech animation timers at 1ms should be changed to 20ms with associated speed adjustments (learned from Road Show).

Timer interval -1 extra execution bug. VPX timers with interval set to -1 execute one additional time AFTER being disabled -- the disable takes effect on the next screen refresh, not immediately. This caused Fish Tales' fishing reel to over-rotate past its target position. The bug is worse in VR (90 Hz) than desktop (144 Hz) because each frame represents a larger time step at lower refresh rates (learned from Fish Tales).

Frame rate dependent animations. Animations using -1 timer intervals produce different speeds on different systems because frame rates vary. Fix: multiply animation increments by a frame-rate-dependent factor (learned from Scared Stiff).

Set timer interval BEFORE enabling. Setting the timer interval after TimerEnabled = 1 causes the first tick to fire at the object's default interval instead of the intended interval (learned from Space Station).

Catch-Up Spiral

Too many separate timers hurt performance, especially in VR. Consolidate multiple timers into one "frametimer" or hijack an existing global timer and call all timer-dependent functions from there. rothbauerw sticks additional update logic into the ball rolling routine to avoid creating new timers (learned from Austin Powers).

ROM Integration

SolCallback Pattern

The SolCallback array maps ROM solenoid numbers to VBS subroutines. Each callback receives an enabled parameter (True/False):

SolCallback(1) = "SolFlipper_L"
SolCallback(2) = "SolFlipper_R"
SolCallback(15) = "SolScoop"

Sub SolScoop(enabled)
    If enabled Then
        PlaySoundAtVol "Scoop_Up", Scoop1, VolumeDial
        Scoop1.kick angle, speed
    End If
End Sub

Critical: SolCallback fires with both True AND False. The sub must include If Enabled Then to only act on activation. Without this guard, solenoid sounds play twice (once on activate, once on deactivate). Add debug.print Enabled to the sol sub to verify behavior (learned from Medieval Madness).

Duplicate SolCallback Assignments

When flashers controlled via SolCallback share numbers with lamp assignments, use a +100 offset to avoid conflicts:

SolCallBack(17) = "SetLamp 117,"

With Lampz.MassAssign(117)=F17 (learned from Judge Dredd).

SetLamp Sub Required for ROM Tables Using Lampz

ROM tables using Lampz need a SetLamp sub that the SolCallback array can reference:

Sub SetLamp(aNr, aOn)
    Lampz.state(aNr) = abs(aOn)
End Sub

This sub is not included in the Example Table by default (since it is a non-ROM table) and must be manually created. Common source of errors when builders try to use the Example Table as a base for ROM-driven tables. The SolCallback references it as SolCallback(17) = "SetLamp 117," (learned from VPW Example Table).

Debug Solenoid Output

Pattern to discover which solenoids the ROM actually fires:

dim chgsol, xxx
chgsol = controller.changedsolenoids
If Not IsEmpty(chgsol) Then
    For xxx = 0 To UBound(chgsol)
        debug.print chgsol(xxx, 0) &" -> "& chgsol(xxx, 1)
    next
End If

Must be called in a timer. Warning: this may "eat" the event from SolCallback, so use only for hunting purposes (learned from VPW Resources).

HandleMech

For tables using stepper motor mechanisms (drawbridges, rotating elements), set HandleMech=0 in the table script. Without this, PinMAME mech handling can interfere with scripted mechanism control (learned from Medieval Madness).

Constants Required by System VBS Files

The constants cSingleLFlip=0 and cSingleRFlip=0 must not be removed from table scripts even if they appear unused. These constants are referenced by the system VBS files for flipper and solenoid management. Removing them broke troll solenoid functionality on Medieval Madness (learned from Medieval Madness).

FastFlips

SAM Implementation

Stern SAM tables require InitVpmFFlipsSAM in table_init for FastFlips to work -- UseSolenoids=2 alone does NOT enable fast flips on SAM ROMs:

Sub Table1_Init
    InitVpmFFlipsSAM
End Sub

InitVpmFFlipsSAM didn't work on newer SAM ROM versions -- the workaround was explicit solenoid mapping in the script rather than relying on automatic detection. FastFlips timing should show under 2ms in the second value of the debug overlay (learned from Tron, Iron Man).

ROM Bypass Alternative

When ROM-based fast flips don't work, bypass the ROM entirely for flipper actuation. Still send the command to the ROM, but don't wait for the solenoid callback response. Instead, fire the flipper directly from within the script on the key press event. Must verify no other game actions are linked to flipper button presses (learned from Tron).

NoUpperFlipper Workaround

Data East tables use different solenoid numbers for FastFlips than Williams. For DE Tommy, the flipper solenoid is 47:

vpmFlips.FlipperSolNumber(2) = 47

Williams tables typically use solenoid 22. This must be set correctly for FastFlips to function on DE/Sega ROMs (learned from Tommy).

Ball Tracking

gBOT vs GetBalls

VPW best practice: create all balls once at table initialization and never destroy them. Use a global ball array gBOT instead of GetBalls calls. This forces the table to behave more realistically -- real pinball machines don't create/destroy balls.

GetBalls is expensive and was being called every frame in update timers. Fix: call GetBalls once and store the result in gBOT. Update only on ball creation/destruction events. This eliminated a significant per-frame allocation and improved performance measurably during multiball (learned from TFTC).

Standalone crash fix: The gBOT variable used in flipper correction code can cause sporadic crashes in the VPX Standalone Player. Fix: use GetBalls in Cor.Update instead of gBOT. The gBOT variable can remain elsewhere in the script (learned from Road Show).

BOT Arrays

If you want to use traditional ball destruction, replace all gBOT references with BOT and use GetBalls where needed. Shadow tracking code that relies on ball IDs will NOT work if balls are destroyed and recreated (IDs change) (learned from VPW Example Table).

When adding ball rolling sounds, the tnob (total number of balls) value must match the ball shadow count, or the shadow update will choke. Script errors on adding extra balls (e.g., via "throw ball") often relate to tnob being exceeded (learned from Austin Powers).

Ball-on-Kicker Detection

For ball locks where balls are NOT visible while locked, use kickers (like in a physical trough) instead of cvpmBallStack. The cvpmBallStack approach destroys and re-creates balls, which sometimes fails. Kickers are more reliable (learned from Road Show).

Ball-in-Narnia Recovery

"Narnia balls" -- balls that fall through the playfield or get lost in impossible locations -- can be detected and recovered:

dim b, i
b = getballs
for i = 0 to ubound(b)
    debug.print b(i).x & " " & b(i).y & " " & b(i).z
next

Lost balls are detectable by their rolling sound continuing after they're visually gone, or by z-position being far below 0. Place a catcher trigger area below the playfield that detects the Narnia ball and places it in a nearby VUK (learned from Batman DE, Ripley's Believe It or Not).

Dynamic Ball Shadow Z-Height Scaling

When a ball travels on a ramp (elevated Z), its shadow should scale proportionally to height and reduce in opacity -- simulating realistic shadow behavior where higher objects cast larger, fainter shadows:

objBallShadow(s).size_x = 5 * ((gBOT(s).Z+BallSize)/80)
objBallShadow(s).size_y = 4.5 * ((gBOT(s).Z+BallSize)/80)
UpdateMaterial objBallShadow(s).material,1,0,0,0,0,0,AmbientBSFactor*(30/(gBOT(s).Z)),RGB(0,0,0),0,0,False,True,0,0,0,0

Important: return original shadow size and material values when the ball returns to playfield level. Avoid writing size and UpdateMaterial every loop iteration -- detect Z threshold crossing and only update once (learned from VPW Example Table).

Sound Integration

Fleep Setup

Three key resources for implementing Fleep sounds: - Fleep Sound Functions VBS (updated): available on Dropbox - Fleep Sounds folder: available on Dropbox - Adding Fleep Sound Package tutorial by rothbauerw

Fleep sound functions require a reference object with X and Y properties near the sound source for positional audio. The argument is the VPX object name string. If the referenced object is renamed or deleted, sounds break silently with no error (learned from Fleep Sounds Guide, TFTC).

AudioPan

Fleep's AudioPan function expects an object with an x property (screen position). Passing the wrong object type (timer, collection) causes a runtime error. Always pass the physical VPX object (wall, primitive, or light) that represents the sound source location (learned from Fish Tales).

Sound panning uses a pan law where center-panned sounds are louder than hard-panned: Left=100%, Middle=200%, Right=100%. This is a code-level limitation that cannot be changed from script side (learned from Fleep Sounds Guide).

PlaySound Parameters

All mechanical sound effects must be connected to the VolumeDial variable:

PlaySound "fx_relay", 1, VolumeDial

VPX sound volume parameter is hard-capped at 1.0 -- values above 1.0 have no effect. If recorded sounds are too quiet even at volume 1.0, the actual audio file must be amplified/normalized (learned from Goldeneye, Rollercoaster Tycoon).

Instead of multiple sound files at different amplification levels, use only the loudest version with a volume parameter to reduce it. The VPX PlaySound volume parameter saturates at 1.0 (cannot amplify), so the loudest sample is the only one covering the full range (learned from VPW Example Table).

LUFS Normalization and Fleep VolumeDial Bug

Fundamental bug in Fleep sound code: some sounds were completely insensitive to VolumeDial adjustment. Fix: add a min() function so the volume parameter is pre-saturated to 1.0 before the VolumeDial scale is applied. Without this, values greater than 1.0 after VolumeDial scaling still saturate at 1.0 in PlaySound, making the dial appear to do nothing (learned from Godzilla).

The min() function itself is defined under the ZMAT section of the Fleep example table. If you get Variable is undefined: 'min', copy that section into your table script (learned from Fleep Sounds Guide).

Playfield Sounds Must Be Mono

VPX 10.8.1 reports validation warnings for stereo WAV sound files. VPX's positional audio system expects mono WAV inputs and handles stereo positioning via pan calculations. Stereo files bypass this positioning and may produce incorrect spatial audio. Convert all WAV sound samples to mono (learned from Ghostbusters, Stargate).

MP3 Preloading

First-time MP3 playback causes frame drops as the audio subsystem loads and decodes. Fix: play all MP3 sound files at near-zero volume during table initialization:

PlaySound "song", 0, 0.001

This pre-caches the audio data so subsequent playback during gameplay doesn't cause frame drops (learned from Goonies).

Physical Trough Implementation

VPW tables use physical troughs (with kicker objects) rather than cvpmBallStack ball management. Physical troughs create all balls once at initialization and never destroy them.

For Gottlieb System 3, the trough uses only 2 switches (outhole + ball release) rather than WPC-style individual ball position switches. The ROM counts balls by tracking enter/exit events. Don't try to implement WPC-style trough switch arrays on a GTS3 table (learned from Stargate).

Pre-loading trough balls at game start eliminates boot sequence delay. Initialize balls directly in their correct starting positions via script rather than letting the ROM route them during startup (learned from Star Trek TNG).

Drop Target Animation

DoDTAnim (drop target animation) and DoSTAnim (standup target animation) can safely be moved to the FrameTimer. They already use the tween method and are "fully clean" (learned from Physics Debate).

Angled Playfield RotX Fix

Drop target primitives using Roth's code require the RotZ=0 orientation to face straight toward the drain. If the primitive mesh is oriented differently at RotZ=0, the target will only respond to direct hits and miss angled ball contacts (learned from Goonies).

Standup targets need backstop primitives (named with "o" suffix, e.g., "sw24o") placed behind the target object. These are collidable but NOT referenced in script -- purely physical barriers allowing the ball to pass slightly into the target for realistic impact while preventing pass-through (learned from Judge Dredd).

Converting VPX Targets to Primitives

Method without requiring Blender: 1. Open a new blank VPX table, delete everything 2. Copy-paste the target from your table into the blank table 3. Position target at 0,0 with RotZ=0 4. File > Export OBJ Mesh 5. Open the OBJ at threejs.org/editor -- delete the playfield mesh, keep only the target, re-export 6. In VPX, create a primitive and import the cleaned OBJ mesh (scale 1)

(learned from Goonies)

Kickback Implementation

For VUK/kicker behavior, the KickZ method parameters are:

KickZ(float angle, float speed, float inclination, float heightz)

Where angle = direction in the X-Y plane relative to the red arrow, speed = ball kick-out speed, inclination = angle above the X-Y plane (90 = straight up), heightz = Z translation before kick velocity is applied. Note: sometimes a small non-zero heightz is required for the method to work. The KickZ on a kicker object is NOT the same as the identically-named method in cvpmBallStack (learned from Road Show).

Kickout Variance

Pattern for adding realistic variance to ejector shots:

Function KickoutVariance(aNumber, aVariance)
    KickoutVariance = aNumber + ((Rnd*2)-1)*aVariance
End Function

Called as Kicker.Kick KickoutVariance(angle, angle_variance), KickoutVariance(velocity, vel_variance) (learned from Twilight Zone).

VPX Script Debugger

VS 2010 Isolated Shell

For full debugging with breakpoints, use the Visual Studio 2010 Isolated Shell integration with VPX. The VPX script debugger supports debug.print for output and allows live expression evaluation.

debug.print

Use the debugger to test random values interactively: press D during gameplay to open the debugger, type debug.print 30+rnd*10 into the editor window and press Enter. The result prints below. Useful for tuning kick-out strengths (learned from Road Show).

Move primitives and query state on the fly: - Execute Flashsol21 true to preview max flasher state - Query values: msgbox F21.intensityscale - Modify: primname.blenddisablelighting = 4 - Force GI: modlampz.state(1)=0 (GI off)

(learned from Judge Dredd)

Depth Bias F5 Debugging

Replace all textures with solid colors during development to isolate lighting, UV mapping, and layer ordering issues. Each primitive group gets a distinct solid color. Makes it easy to spot missing ON/OFF pairs, incorrect material assignments, and depth bias problems (learned from Judge Dredd).

Error Handling Patterns

On Error Resume Next for Version Compatibility

VPX 10.8 added script API access to refraction probes. To maintain backward compatibility with older versions, wrap probe-related calls in error handling:

On Error Resume Next
    ' 10.8+ refraction probe code
    RefractionProbe.Roughness = 0
On Error Goto 0

This lets a single script work on both 10.7 and 10.8 -- the probe commands silently fail on older versions (learned from Fish Tales).

VBScript Boolean Gotcha

If a variable is set to 1 (integer) instead of True (boolean -1), then Not variable returns -2 instead of 0. This caused FreePlay and CabinetMode checks to fail:

' Bad:
If Not cabinetmode Then ...  ' fails when cabinetmode = 1
' Good:
If cabinetmode = 0 Then ...

(learned from Game of Thrones)

Const Must Be Defined Before First Reference

Code that references a Const variable before it is defined (e.g., LoadValue(cGameName, ...) appearing before Const cGameName = "rctycn") will crash on the VPX Standalone Player. The VBScript engine on standalone is stricter about declaration order than on Windows (learned from Rollercoaster Tycoon).

Version-Specific APIs

DisableStaticPrerendering

VPX 10.8.1 changed DisableStaticPreRendering to track enable/disable count rather than being a simple toggle. Tables that previously spammed DisableStaticPreRendering = True in the options menu callback must update to only set it True once when entering the menu and False once when exiting. The old approach of setting it every time an option changes breaks in 10.8.1 because each True increments a counter and each False decrements it (learned from VPW Example Table).

Refraction Probe Script API

VPX 10.8 added script access to refraction probes. Use On Error Resume Next for backward compatibility with 10.7 (learned from Fish Tales).

light_animate Subs

VPX 10.8+ supports light_animate event subs that fire whenever a light's IntensityScale changes, replacing Lampz for controlling 3D insert primitive BlendDisableLighting:

Sub l86_Animate
    LaunchButton.blenddisablelighting = l86.GetInPlayIntensity / 10
End Sub

The light must be added to the alllamps collection, and the light's timer interval must be set to the correct lamp number. Using _Animate is preferred over a timer -- it only fires when intensity actually changes (learned from VPW Example Table, Johnny Mnemonic).

vpmMapLights

Modern insert light setup uses vpmMapLights instead of manual lamp arrays: set each VPX light's TimerInterval property to the ROM light index number, then add all mapped lights to an AllLamps collection. Combined with UseLamps=1, this eliminates manual lampz callback code for standard inserts (learned from Lethal Weapon 3).

VP Unit Conversion

Standard conversion factors: - 50 VP units = 1.0625 inches - 47.059 pixels per inch (VPX Dimension Manager rounds to 47) - Ball diameter: 50 VP units - Ball radius: 25 VP units

The VPX Dimension Manager uses a rounded value of 47 for its internal conversion factor instead of the precise 47.059. When precision matters, manually calculate positions using 47.059 (learned from Iron Man).

Common Script Bugs

Duplicate Sub Names Silently Override

VPX does not always throw an error for duplicate sub names. Having two LeftFlipper_Collide(parm) subs causes the first to be silently overridden -- physics functions like CheckDampen and CheckLiveCatch never execute. The underscore syntax is VPX's event handler convention; the physics/main sub should keep the underscore name (learned from Austin Powers).

vpmTimer.Add is Broken

vpmTimer.Add is broken and should never be used. Use standard VPX timers or custom timer implementations instead (learned from TNA).

Namespace Collision with globalplugin.vbs

VPX loads globalplugin.vbs (if present) into the same script namespace as the table script. If both define subs with the same name, the globalplugin version silently overrides the table version, causing type mismatches and crashes. Prefix custom functions with table-specific identifiers (learned from Iron Maiden).

VPX Object Name Limits

VPX has a 31-character limit on object names. Toolkit-generated names exceeding this limit get silently truncated, causing name collisions and script reference failures (learned from Iron Man).

Primitive names containing special characters (quotes, slashes) break VPX's auto-generated VBS array declarations (learned from Medieval Madness).

Pure Black Breaks Rendering

Never use pure black (RGB 0,0,0) in VPX textures. It causes divide-by-zero errors in renderers and makes lights not work properly. Use minimum RGB 17,17,17 (#111111) for the darkest blacks (learned from Johnny Mnemonic, Stargate).

VPX Saves Table State on Exit

If the table tilts and then crashes, bumper and slingshot threshold values can be permanently set to 100 (disabled) in the saved state. Fix: set all pop bumper and slingshot thresholds at table init (learned from Jokerz).

Flasher Filter Strings Are Case-Sensitive

Flasher object .filter property strings are case-sensitive in VPX: "Additive", "Multiply", "Overlay", "Screen" must match exact capitalization (learned from Judge Dredd).

Locale-Dependent Physics Corruption

Fixed in VPX 10.8. VPX 10.7 had a critical bug where non-English locales (Finnish, or any locale using comma as decimal separator) caused physics values to corrupt silently. String-to-float conversions read "0.5" as "0" when the locale expects "0,5". Niwak wrote a CNCDbl() function (Culture-Neutral Conversion to Double) as a workaround (learned from Simpsons Pinball Party).