Scenarize a battle
This guide explains how to use battle events: Ruby hooks into the battle scene that show mid-battle dialogue, set up initial effects, react to moves and force the AI, selected per battle by its battle id.
What battle events do
A battle event is a Ruby file that hooks into the battle scene at specific moments, to make a fight feel scripted rather than mechanical. Three things stand out:
- Show messages: mid-battle dialogue, typically the enemy trainer talking.
- Set up the logic once it is loaded, for example starting the battle with an effect already active.
- Force the AI to take a specific action, a lighter alternative to writing a full custom AI for a gym leader or an elite.
Creating a battle event file
Battle events live in Data/Events/Battle/ as Ruby files. The filename must start with five digits, followed by anything, then .rb:
Data/Events/Battle/00234 Brock battle.rb
The five digits are what matters: they are the battle_id the scene looks for.
Selecting which events load
Battle::Logic::BattleInfo#battle_id decides which file loads. A battle_id of 5 loads Data/Events/Battle/00005*.rb. There are two ways to set it:
- From a script, on the
BattleInfo:bi.battle_id = 5(see the battle scripting guide). - In Pokémon Studio, by setting the trainer's Battle Group ID to a non-zero value.
A few things to keep in mind:
battle_iddefaults to-1, which loads nothing. Events only run once you set it.- A wild battle uses
battle_id = 1by default (so00001*.rb), unless you call$wild_battle.setup(battle_id)yourself.
Avoid two files sharing the same five digits (00005 Brock.rb and 00005 Misty.rb): only one of them loads, and which one is undefined.
Registering an event
An event file reopens Battle::Scene and registers a block for each moment you want to hook into. Reopening the class groups every event of the file together and avoids repeating the Battle::Scene. prefix on each call:
module Battle
class Scene
register_event(:battle_begin) do |scene|
# ...
end
end
end
The block always receives the battle scene as its first argument; some events pass more. Only one block can be registered per event name: registering the same event twice keeps the last block.
The snippets below show the register_event calls on their own; each one goes inside that module Battle / class Scene block. The fully qualified form Battle::Scene.register_event(...) works too at the top level of the file, it is just more verbose.
Showing mid-battle dialogue
The most common use is making the enemy trainer talk. PSDK does not ship a ready-made method for it, so add this helper once. It slides the trainer sprite in, shows the messages, then slides it back out:
module Battle
class Scene
# Show messages from the enemy trainer during battle.
# @param messages [Array<String>]
# @note wraps everything in visual.lock
def show_event_message(*messages)
visual.lock do
sprite = visual.battler_sprite(1, -1) # negative position = trainer sprite, -1 = first trainer
slide_in = Yuki::Animation.move(0.4, sprite, 320 + sprite.width, sprite.y, 290, sprite.y)
slide_in.start
visual.animations << slide_in
visual.hide_team_info
visual.wait_for_animation
messages.each do |message|
message_window.blocking = true
message_window.wait_input = true
display_message_and_wait(message)
end
slide_out = Yuki::Animation.move(0.4, sprite, 290, sprite.y, 320 + sprite.width, sprite.y)
slide_out.start
visual.animations << slide_out
visual.show_team_info
visual.wait_for_animation
end
end
end
end
Place it in a custom script, not in the battle event file, so every battle can use it. The examples below assume it exists.
show_event_message calls visual.lock itself, so never wrap a call to it inside another visual.lock: locking the visuals twice raises a race-condition error.
The available events
PSDK fires eight events over the course of a battle. They are all registered the same way; they differ in when they fire and in the extra arguments the block receives.
| Event | Block arguments | When it fires |
|---|---|---|
:logic_init | |scene| | Right after the scene is built, before any animation. Set up the logic here. |
:pre_battle_begin | |scene| | During the intro, after "Trainer wants to fight!", before any Pokémon is sent out. |
:battle_begin | |scene| | After both sides sent their first Pokémon, before the first player choice. |
:trainer_dialog | |scene| | Each turn, after the player has chosen, before the AI chooses. |
:AI_force_action | |scene, ai, index| | Each turn, once per AI. Return actions to force them, or nil for default AI. |
:after_attack | |scene, launcher, move| | After each move resolves. |
:after_action_dialog | |scene| | After the turn's actions, before fainted Pokémon are replaced. |
:battle_turn_end | |scene| | At the end of the turn, after fainted Pokémon are replaced. |
Setting up the logic
:logic_init runs before any animation, while the logic exists but nothing is on screen yet. It is the place to add starting effects:
register_event(:logic_init) do |scene|
scene.logic.bank_effects[1].add(Battle::Effects::LightScreen.new(scene.logic, 1, 0, Float::INFINITY))
scene.logic.bank_effects[1].add(Battle::Effects::Reflect.new(scene.logic, 1, 0, Float::INFINITY))
end
The battle now starts with Light Screen and Reflect already up on the enemy side for an unlimited number of turns. bank_effects[bank] holds a bank's effects, and the effect constructor takes (logic, bank, position, turn_count).
Making the trainer talk
Five events pass only scene, so any of them can call show_event_message; they differ only in timing (see the table). A line before the player's first move uses :battle_begin:
register_event(:battle_begin) do |scene|
scene.show_event_message("You actually made it this far? Let me show you a real battle!")
end
A first-turn-only line uses :trainer_dialog with a turn guard:
register_event(:trainer_dialog) do |scene|
next if $game_temp.battle_turn != 1 # only on the first turn
scene.show_event_message("Oh, I forgot to mention, I never intended to play fair.")
end
A "down to the last Pokémon" boast uses :battle_turn_end (which fires after fainted Pokémon are replaced), with a flag so it runs only once:
register_event(:battle_turn_end) do |scene|
next if scene.logic.alive_battlers_without_check(1).size > 1
next if scene.instance_variable_get(:@ace_dialog_done)
scene.instance_variable_set(:@ace_dialog_done, true)
scene.show_event_message("You are pushing me to my limits! Here is my best Pokémon!")
end
The @ace_dialog_done flag, stored on the long-lived scene, stops the event from firing every later turn. :after_action_dialog is the same idea one step earlier, before the replacement.
Reacting to a move
:after_attack fires after every move, with the attacker (launcher) and the move. Use the move's public predicates to react, no need to reach into its internals:
register_event(:after_attack) do |scene, launcher, move|
next if launcher.bank != 0 # only react to the player's moves
next if scene.instance_variable_get(:@praised_player)
if move.super_effective?
scene.instance_variable_set(:@praised_player, true)
next scene.show_event_message("Nicely done! You really know how to hit a weakness.")
end
if move.critical_hit?
scene.instance_variable_set(:@praised_player, true)
scene.show_event_message("Ouch, that critical hit must have hurt!")
end
end
move.super_effective?, move.critical_hit? and move.effectiveness are all public.
Forcing the AI
:AI_force_action fires once per AI (index is its position, ai is the AI). Return an array of actions to override its turn, or nil to let the default AI decide:
register_event(:AI_force_action) do |scene, ai, index|
next if index != 0 # only the first AI
controlled = ai.controlled_pokemon
next if controlled.empty?
next unless scene.logic.can_battler_be_replaced?(active = controlled.first)
bench = ai.party.select { |pokemon| pokemon.alive? && !controlled.include?(pokemon) }
next if bench.empty?
next [Battle::Actions::Switch.new(scene, active, bench.sample)]
end
This forces the first AI to switch its active Pokémon for a random benched ally. Battle::Actions::Switch.new(scene, out, in) builds a switch action (the first Pokémon leaves, the second comes in), and returning the array makes the AI perform it.
Defining your own events
The eight events above are the ones the battle scene fires, but register_event accepts any symbol, so a plugin can expose its own hooks. Triggering an event goes through call_event, which is private on the scene, so mirror the engine's own on_after_attack / on_pre_battle_begin wrappers and add a public method that calls it:
module Battle
class Scene
def on_low_hp_taunt(launcher)
call_event(:low_hp_taunt, launcher)
end
end
end
Call scene.on_low_hp_taunt(...) wherever your logic needs the hook (typically from a prepended battle method), and event files react to it like any built-in event:
register_event(:low_hp_taunt) do |scene, launcher|
# ...
end
This is exactly how the engine fires :after_attack, :pre_battle_begin and :battle_turn_end internally.
Putting it together
A battle event file is several register_event calls inside one reopened Battle::Scene:
module Battle
class Scene
register_event(:logic_init) do |scene|
scene.logic.bank_effects[1].add(Battle::Effects::LightScreen.new(scene.logic, 1, 0, Float::INFINITY))
end
register_event(:battle_begin) do |scene|
scene.show_event_message("So you finally made it. Do not expect me to go easy on you!")
end
end
end
Set the battle's battle_id to the file's digits (from a script with bi.battle_id = 2, or via the trainer's Battle Group ID in Studio), add the show_event_message helper to a custom script, and the scene runs each block at the right moment.
Conclusion
- Battle events are Ruby files in
Data/Events/Battle/NNNNN*.rb, selected bybattle_id(default-1, which loads nothing;1for wild battles). - Reopen
Battle::Sceneand register one block per moment withregister_event(:event); the block always receivesscenefirst. - Use the dialogue events with the
show_event_messagehelper for mid-battle lines,:logic_initfor starting effects,:after_attackto react to moves, and:AI_force_actionto script the AI. - Set
battle_idfrom aBattleInfo(bi.battle_id = N) or via the trainer's Battle Group ID in Studio. - Beyond the eight built-in events, register your own symbol and trigger it with a public
on_*wrapper aroundcall_eventto expose battle hooks from a plugin.