Scripted stages

game.custom_value_1 through game.custom_value_16 are signed integers in GameModel. They are included in rollback snapshots, start at zero for a new game and persist across scene/level transitions. Reserve slots centrally across the mod and its dependencies. Do not store gameplay state in Lua globals or upvalues.

A scene's updateScript: {packName, scriptName} runs once per simulation update, after emulated-lag rejection and before entity updates. It has no current entity. Use game.get_player etc. It does not run during entrance/exit transitions.

While a gameplay cinematic runs, the scene's simulation is frozen but its art keeps animating: frame (the global timer that clocks layer visual scripts and palette cycles) keeps counting. The update script is skipped, unless the scene sets updateScriptDuringCinematics: true: it then also runs on each cinematic update, after the cinematic script, with game.is_cinematic true. That run is for presentation only (a bobbing camera Y, the custom values that drive layer visual scripts): game.spawn_scene_enemy and game.spawn_scene_item return -1 in it, and the script must leave its own progress (clocks, spawn streams) alone until game.is_cinematic is false. A camera_y it writes overrides the cinematic script's, and is the one kept when the cinematic ends (the camera X still returns to where the cinematic started).

postUpdateScript uses the same reference format and runs once after entity animation and native weapon attachment placement at the end of a completed gameplay update. It has no current entity. Use it when placement depends on the final animation frame; its return value is ignored.

enterScript has the same reference format. It runs once after native player placement, before enemies spawn. Set scriptedEntrance: true when that script owns placement: this preserves the fade delay without walking the players back to the Classic starting positions. Reset per-visit timers in this hook, not in Lua globals. The update hook's return value is ignored.

scriptedProgression: true disables automatic wave/scene advancement and the engine's automatic GO prompts. The script owns encounter boundaries and may call game.schedule_go_forward_effect() between encounters. Call game.change_scene(zeroBasedSceneIndex) to enter any scene in the current level. It returns false for an invalid index; on success, return from the script immediately. The next update uses normal scene initialization, clearing enemies and unheld items, resetting spawn cursors, and running enterScript. Use this from the scene update hook. game.scene_enemies_cleared checks both remaining loadcues and active enemies, including the spawn delay, so a gap between two waves is not mistaken for scene completion.

For a normal forward transition, call game.complete_scene() and return from the script. It enters the scene's sceneExitCodeId strategy (walking/fade or stage clear), then the next scene's normal entrance. It returns false outside active gameplay, so repeated calls cannot restart a transition. Use this instead of advance_wave() or advance_level() when the exit presentation must play.

A physical scene may contain several scripted camera-lock waves. Declare scriptedWaveCount and scriptedWaveCustomValue (1–16) to expose those waves in the custom-game menu. Starting at a selected wave seeds that game custom value with a one-based wave index before the entrance script. The script must honor that seed for a new game, position the camera/player and skip earlier spawns. Ordinary transitions should reset the index to the first wave. Zero/default counts retain one wave per scene. This does not create extra physical scenes; existing scene selection IDs and replays retain their original meanings.

scriptedEnemies: true disables automatic enemy cues for that scene. Use game.spawn_scene_enemy(index) to instantiate one zero-based enemies1 cue through native enemy initialization. It returns the enemy object ID, or -1 if invalid or all slots are occupied. It does not advance cue cursors or recursively spawn later cues. The script must retry a failed spawn and record started/cleared encounters in snapshot variables. In these scenes scene_enemies_cleared means no active enemies; future scripted encounters do not prevent the current area from unlocking. Ordinary scenes retain the full cue/delay completion check above.

The custom game settings apply to scripted spawns without any work from the script. Settings that act on the enemy as a whole wait until the update that spawned it has finished, so they see the health, custom values and position the script left on the enemy:

  • Enemies health multiplier: applied to the health the script set, extra bars included (the script's bar size is kept). It is applied at the end of the frame in which the enemy's AI script first owns an update, so health set by a stage spawn routine or by the enemy's own AI on its first tick is both covered; an enemy without an AI script is done on its spawn frame. Health an AI rewrites later (a formation breaking up, a phase change) is the script's own.
  • Enemy count multiplier: every spawn is duplicated as an exact copy of the configured enemy (custom values, health, position nudged sideways), so a Lua driven enemy's double runs the same behaviour. Copies take enemy slots like any spawn and are skipped when none is free. Bosses are doubled too, all at once with their source, and a copy of an enemy flagged is_boss is a boss as well: as with native bosses, only the last boss down runs the boss finale (the death controller drops is_boss from the others as they fall). A boss script with its own post-defeat sequence (an escape, a death scene that ends the stage) should run it only when no other boss is still alive, and a stage that waits for its boss should look for any live is_boss enemy rather than one slot. Copies are extra to the stage's pacing, like native doubles are extra to the loadcues: number_of_alive_enemies leaves them out (so "spawn the next one when N are left" sees only the stage's own enemies), except that it never reads 0 while a copy is still alive, so a wave still ends only once the copies are down. The sideways nudge alternates sides while the source is on screen, but never puts a copy further outside the view than its source: copies of an enemy placed ahead of the view (a resident that wakes when the screen reaches it) sit between it and the view, never beyond an area barrier the camera stops at. More enemies also means more drops: every item slot can be taken, so a stage that places scenery or weapons with spawn_scene_item should not let a failed item spawn hold back its enemy spawns.
  • Randomizer (Royal / Swaps / Shuffle): a non-boss spawn may be re-typed as another of the pack's own enemy kinds, picked from every entity the pack's levels spawn outside a boss slot. Everything else the spawn set (health, custom values, spawn record) stays, and the new kind's own AI takes over; its display name and palette become the new kind's. Knockouts re-types a knocked out enemy the same way. "Spawns" only rolls Classic entrances and leaves custom packs alone. A pack whose enemies depend on their cue (a scripted entrance, a weapon spawned for that kind) should expect the new kind to arrive with the old kind's record.
  • Enemies velocity multiplier: applied when a script owned move is integrated (velocity_x / velocity_y left on an enemy by its AI, or an explicit update_position_and_check_world_collisions() call). The stored velocity is unchanged afterwards. Positions a script writes directly are not scaled.
  • A script that spawns or moves its enemies itself, outside engine spawns and velocity integration, reads enemies_multiplier, enemies_velocity_numerator and enemies_velocity_denominator and applies them on its own (TwinBee lengthens its waves and scales each enemy's per-tick movement). They come from the session, so every netplay peer reads the same values; read them every tick instead of storing them.
  • Instant spawns: scripted stages pace their own spawns, so read game.has_instant_spawns and drop the delays yourself.

Damage multipliers, infighting, killer, exploding and Ninpo enemies act on the native hit and death handling and need nothing from the script. The exception is killer mode's attack timing for an enemy whose script writes its animation frames itself: see Killer Mode.

The engine's own "GO!" prompt (shown when no enemy is left and the scene can still scroll, then every 15 seconds, or after 5 seconds without enemies) waits 5 seconds after a stage or scene start, including advance_wave() and change_scene(), before its first appearance. In a scriptedEnemies scene this gives the script time to spawn its first enemies; call schedule_go_forward_effect() to show it yourself.

scriptedItems: true disables automatic item cues. Use game.spawn_scene_item(index) to spawn one zero-based items cue through native initialization, including container contents and flags. It bypasses the camera filter and does not advance any cue cursor. It returns the object ID, or -1 for an invalid cue or exhausted pool; retry a failed allocation. A cue with status bit 7 uses a sub-item slot. Record successful allocations in snapshot state.

preloadItems: true spawns item cues without the normal one-way camera-position filter, so switches behind the starting camera still exist when players backtrack. Available item slots still limit simultaneous objects; keep the fixture count bounded.

scriptedClock: true disables the normal HUD countdown and its time-out damage. Use game.set_clock(integer) and implement the desired consequences in the simulation hook. game.difficulty_index is the zero-based configuration index; game.screen_width is the current playable width in pixels. game.enemy_capacity is the active enemy-slot count; enemy object IDs start at 4.

Set game.scripted_camera = true to disable automatic scrolling. Set game.camera_x and game.camera_y in viewport pixels, positive right/down. Collision, spawning and rendering read that same camera state. Scripts must choose bounds and multiplayer framing; the engine does not clamp the camera. Rendering follows scripted cameras smoothly in both horizontal directions. Clear scripted_camera to restore normal scrolling. Scene entrance resets it.

Lua numbers in this engine are 32-bit integers, not floating point. Like every script, a stage hook returns its update function, which the engine calls on every update (see Script structure). Camera pixels and entity bridge coordinates use different units: a pixel coordinate converts to an entity position using (pixels * 65536 + 500) / 1000 (integer division). For example, a camera stationary at pixel 500:

return function()
    game.scripted_camera = true
    game.camera_x = 500
    game.camera_y = 0
    game.custom_value_1 = game.custom_value_1 + 1
    return false
end

Indexed tile layers

An art layer's tilemap contains width, height, tiles, cells, palette and optional paletteCycles and scale. Tiles are base64 4bpp: 32 bytes per 8x8 tile, rows top-to-bottom, high nibble first. Cells are row-major 16-bit Genesis words: bits 0..10 tile index, 11 horizontal flip, 12 vertical flip, 13..14 palette line, 15 foreground priority. palette contains 64 Genesis 0BGR color words. Index zero of each line is transparent. Layer position is the top-left origin; scale (default 1) multiplies the layer's size around that origin, for art authored at a different resolution. The engine accepts 1..2048 tiles, maps up to 2048x2048 cells, and even-channel Genesis color words (color & ~0xEEE == 0). A color of -1 is transparent, like index zero: palette cycles can use it to hide and reveal pixels, for example a dissolve whose frames turn indices on one after another. Invalid tile references, palette lengths, and palette cycle ranges are rejected. tools/validate_mod.py checks these constraints and scene/layer script references before loading the mod. Priority tiles render 16 world Z units ahead of the layer: a layer at Z=10 places its priority tiles at Z=-6, matching the native foreground convention.

Each cycle is {startIndex, ticksPerFrame, frames}. Frames are equal-length arrays of replacement color words. Cycles derive their phase from the simulation timer, so rollback and pause do not drift the phase. Later overlapping cycles win. Optional preserveInitialPalette: true keeps the authored base colors before ticksPerFrame; the first boundary then selects frame one (normal cycle phase). The default applies frame zero immediately. The packed art is included directly in exported JSON; no runtime ROM is needed.

Visual scripts

A layer's visualScript: {packName, scriptName} uses a separate VM with no gameplay bridge. It receives only a copied visual table: frame, simulation_frame, camera_x, camera_y, level_id, scene_id, live_level_id, custom_values[1..16], quake_force, quake_force_milli, quake_skip_frames, quake_sequence. The milli value retains fractional quake strength as an integer multiplied by 1000. frame is the gameplay timer; simulation_frame is the viewed simulation frame and continues during scene entry/exit transitions. Use simulation_frame for continuously animated scenery. It stays fixed when simulation is paused and follows rollback; it is not a wall-clock or render-frame counter. A delivered quake increments the sequence; cancellation clears its force. As with gameplay scripts, the chunk runs once and returns an update function, which the view calls every frame. That function returns a table containing optional offset_x, offset_y (pixels, positive right/up), offset_x_milli, offset_y_milli (thousandths of a pixel, added to the pixel offsets: Lua numbers are integers, so this is how a layer moves by sub-pixel steps), visible, palette_tick to override the palette clock (for example, starting gas colors when an alarm activates), and, for a tilemap layer, first_cell and cell_count: only the cells first_cell to first_cell + cell_count - 1, counted row-major from zero, are drawn (by default all). A typewriter reveals its text by growing cell_count; a strip of tile variants shows one of them with cell_count = 1 and an offset_x that moves it into place. The visual table is the same object on every call, refilled before each one. When none of its values changed since the previous call (several render frames on one simulation frame, a pause), the function is not called and its previous result stays: it must return the same result for the same inputs, so keep counters in the inputs, never in the script.

level_id and scene_id are those of the art being shown: while a cinematic shows another scene's art (set_cinematic_presentation), they are that scene's. live_level_id is always the live fight's level, whose entities are the ones on screen: a layer can use it to draw, in a borrowed scene, what that scene's own items would have drawn.

In a cutscene (including ending screens) there is no game state: frame counts frames since that cutscene scene started, custom_values are all zero, and tilemap palette cycles follow the same clock. A cutscene built from tilemaps and visual scripts loads at once: its art is in the JSON and no image files are read. The Streets of Rage 1 campaign's ending is an example: its planes and sprites are tilemap layers, its palette fades are palette-cycle frames, and its scripts pick the frame, the position and the credits text cells from frame.

Visual scripts cannot mutate health, collision, RNG or stage flow. Prefer effects derived from frame/snapshot values. Rollback may redeliver visual quake events; never use visual state for gameplay decisions.

-- An alarm selected by rollback state, with a palette clock starting at zero.
return function()
    return {
        visible = visual.custom_values[2] ~= 0,
        palette_tick = visual.frame - visual.custom_values[3]
    }
end

The visual VM deliberately has no game, IO, OS or CLR access, and like the gameplay sandbox no math library (numbers are 32-bit integers). Its scratch state is not serialized. Gameplay hooks must derive all persistent state from the game/entity model; a visual hook must never be used to decide gameplay. GameModel layout changes require matching engine builds for replays/netplay.