Files
gen1recomp/docs/rfcs/0014-mod-driven-actors-and-adopted-link-sessions.md
T
DESKTOP-8SRFDDM\cam95 b550db3abb rfc: guard the new seams and write RFC 0014
Route B in CONTRIBUTING-mods.md asks an event/hook change for five things.
This adds the two that were missing and fixes what the third turned up.

link.battle_ended built its payload unconditionally. Route B is explicit
that a new event must not allocate when nothing wants it, and every other
emit in the engine already guards -- Runtime.wants now gates this one too,
so an unsubscribed build runs the branch exactly as it did.

world.talk was handing Runtime.call a closure built fresh on every A press,
purely to have a fallthrough to pass. It is a file-local now, so an unhooked
press allocates nothing it did not allocate before.

The RFC covers motivation, the API delta with call sites, migration (nothing
changes for existing mods), and verification. The backward-compatibility
statement is in it: every item is a new name or a new optional argument, and
example_mew_starter -- api 1, category = "GAMEPLAY", whole-species copy --
still loads, which run_modkit proves on every run.

No registry or schema field is added, so gen_registry_docs has nothing to
emit for this change. Running it does show pre-existing drift in
docs/modding/reference/registries.md (timeFishGroups, an objects refinement)
from earlier Schemas.lua edits that were never regenerated; that is not this
branch's to carry, so it is left alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 10:35:12 -05:00

7.0 KiB

RFC 0014: Mod-driven world actors and adopted link sessions

Status

Proposed.

Motivation

A mod can already spawn a runtime object with mod.world:spawnNpc and can already start a link battle. It cannot make either of them behave like something the mod itself owns.

Three walls, each of which stops a mode rather than inconveniencing it:

An actor cannot move on its own schedule. The only public way to animate a spawned object is Handle:scriptMove, which queues onto OverworldState.scriptMoves. A non-empty scriptMoves is how the overworld knows a cutscene is running -- handleInput gates on it -- so anything animated that way freezes the player's controls for as long as it walks. That is correct for Oak marching into his lab and wrong for an ambient walker or a networked player's ghost, which move continuously and must not lock anyone out.

A spawned object cannot answer the A press. Talking is resolved from the map's text tables by TEXT_* id. A runtime object has no id, so the vanilla path has nothing to say for it, and the mod that created it has no way to say anything either.

A link battle cannot ride a connection the mod already has. LinkState owns pairing, so a mode that already has a socket to its peer must either open a second connection for the battle or reimplement lockstep. And when the battle ends, cable rules leave the real party untouched, so the damage exists only in the battle's own copies -- which are gone by the time the state unwinds. A mode built on link battles cannot learn what the fight cost.

The immediate consumer is an overworld multiplayer mode, but nothing here is specific to it: the first two are wanted by any mod with an actor that moves itself, and the third by any mode that runs battles over its own transport -- a tournament ladder, a draft, a gauntlet.

The decision it extends

This adds nothing to the compatibility surface's shape; it extends the additive, guarded seam convention that Route B in CONTRIBUTING-mods.md documents, and is gated by the parity guarantee tests/engine/gate_meta_coverage.lua enforces ("21-testing-and-ci: a parity gate for every extension point"; M14).

There is no in-repo D-number registry to amend; the consuming design lives outside this repository, as it did for RFC 0011.

Exact API delta

New hook: world.talk

mod.hooks:wrap("world.talk", function(next, ow, target)
  -- ow     = the OverworldState raising it
  -- target = the object on the faced cell
  if mine(target) then
    say(target)
    return                    -- the mod answered; the text path is skipped
  end
  return next(ow, target)     -- anything else falls through unchanged
end)

Call site: OverworldState:interact, on the branch that has already resolved an object on the faced cell (including across a counter) and confirmed it is not mid-step and not the Pikachu follower. It runs before the map's text tables are consulted. With no subscriber, Runtime.call invokes the vanilla fallthrough, which is a file-local function rather than a per-press closure, so an unhooked A press allocates nothing it did not allocate before.

New event: link.battle_ended

mod.events:on("link.battle_ended", function(ev)
  -- ev = {
  --   result     = "win" | "lose" | "draw" | "ended",
  --   myParty    = lockstep copy of our party,
  --   theirParty = lockstep copy of theirs,
  --   peerName   = string,
  --   role       = "host" | "guest",
  -- }
end)

Call site: LinkState:update, in the battleRunning stage, once the battle state has been popped and before exitWith unwinds the link stack. Guarded by Runtime.wants("link.battle_ended"), so with no subscriber no payload table is allocated and the branch runs exactly as before.

The party copies are the point of the event. They are the lockstep records the battle actually fought with, which is where the damage lives under cable rules.

New WorldAPI handle methods

handle:stepNow(dir)        --> true when the step started
handle:canStep(dir)        --> would stepNow land somewhere legal?
handle:placeAt(x, y, dir)  --> snap, no animation, clears a step in flight
handle:isMoving()          --> true while a step is animating
handle:setPassable(flag)   --> may the player walk through this object?

stepNow sets the same per-tile state scriptMove does, without the queue and therefore without the input lockout. It does not consult collision: the intended caller is replaying a move already decided elsewhere (validated on a peer, or authored), and re-judging it locally would let two copies of the same actor disagree about where it is. canStep is the separate opinion for callers that want it. setPassable sets the flag Collision.occupied already honours for Yellow's companion Pikachu; a passable object still draws and still talks.

Two supporting changes

Game:startNewGame(opts) -- the title screen's NEW GAME closure, made callable, with opts.intro = false to land directly in the world. A mode that issues its own starting state has no use for the intro cutscene.

CodeEntry.new(shape) -- accepts an optional { length =, charset = }, so the slot-scrub widget that enters a link code can also carry a room code or a host:port address. Called with no argument it is byte-for-byte the previous widget.

Migration and compatibility

Existing mods change nothing. Every item above is a new name or a new optional argument; no existing name, payload, signature, or default changes.

The v1 surface is unaffected: content.X:register/override/get, events:on, hooks:wrap, mod.log, mod:read, the manifest v1 fields and pokemon.before_give all behave as before. mods/example_mew_starter -- api 1, category = "GAMEPLAY", whole-species copy -- loads unchanged, which tests/run_modkit.lua proves on every run.

With no subscriber to either seam, Red, Blue, Yellow, Gold and Silver behave exactly as they did: the A press reaches talkTo, the link battle unwinds without building an event payload, and no handle method is reachable unless a mod calls it.

Verification

  • tests/modkit/cases/world_talk.lua drives the real OverworldState:interact path. It asserts the unhooked build first (the A press reaches talkTo), then that a mod owning the object suppresses the text path by not calling next, that an object the mod ignores still falls through, and that an object mid-step raises no hook at all.
  • tests/modkit/cases/link_battle_ended.lua drives the real LinkState:update path. It asserts that with nothing subscribed the event is not wanted (so no payload is built) and the battle still unwinds, then that a subscriber receives the result, the role, the peer name and both party copies including the damage the real party never took, from both sides of the cable.
  • tests/engine/gate_hooks.lua and tests/engine/gate_events.lua walk the live catalog, so both seams are parity-gated structurally; gate_meta_coverage passes 208/208 with both names covered and no DEBT entry added.

Deprecation etiquette

Nothing is removed, renamed, superseded or deprecated.