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_bossis a boss as well: as with native bosses, only the last boss down runs the boss finale (the death controller dropsis_bossfrom 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 liveis_bossenemy rather than one slot. Copies are extra to the stage's pacing, like native doubles are extra to the loadcues:number_of_alive_enemiesleaves 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 withspawn_scene_itemshould 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_yleft on an enemy by its AI, or an explicitupdate_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_numeratorandenemies_velocity_denominatorand 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_spawnsand 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.