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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
(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:
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).