Run scripts — routing a player-mode route
How a game ("game A" below; "game B" = a 3D game) gets a player-mode run script (input only: clicks, keys, the game's own UI) across a large world, and
what any game reuses. The lib's runner and step format: src/Godosa.Core/Runs/Scripts/. Code:
src/Godosa.Core/Runs/RouteLegs.cs (pure, tested in Godosa.Core.Tests/Runs/RouteLegsTests.cs).
The method: scout headless, then play once in the window
- Scout with debug-mode scripts run headless (no window, seconds per run): teleport, then ask questions through
game cheats that print answers to stdout (
cheat "route=…","hops=…","path=…","near=…","find=…"). Debug mode never reaches the player script; it only finds the facts. - Write the player steps from those answers (
travel-tolegs,walk-pathtargets, portal clicks, answer texts). Cite the fact in a comment above the step (why the detour, which script opens what). - Play the chapter once in the window from the last checkpoint; the checkpoint cache makes reruns resume at the
first changed step. Fix from the failure report (
out/runs/<run>/report.txt+fail.snap), rerun.
Scouting from a fresh game misleads where the world changes with play (opened seals, dead NPCs, decayed bodies):
check a finding against the run's own fail.snap (the save doc) when it disagrees with the window run.
Overland: RouteLegs.Path + RouteLegs.Legs
- The world is a grid (sectors).
Path= breadth-first, 8 neighbours, over the game's passability (blocked(x, y)). - The game's own router (what a click on the world map does) takes only some trips: greedy steps, short detour windows,
refusals across water or mountains.
Legscuts the BFS path into the farthest stops that router accepts (accepts(stop, cell)= the game's router finds a way): each stop becomes onetravel-toleg. - Legs are direction dependent: the reverse of a working leg list can be refused. Scout each direction.
Legsreturns null with the stuck stop: report it (the router is stuck at …) instead of guessing.
Seals: RouteLegs.Seals
Script-sealed cells (a pass a tile script opens, a bridge a toll opens) are blocked like walls until the story opens
them. When Path finds nothing, Seals reruns it with the soft cells open and names the ones a way would cross (at
most 8, start to goal); empty when it is walled off anyway or already open. A refusal that names seals tells the author
to walk across instead of travelling, or to open the seal first. A sealed goal cell is still reachable on foot: travel
to the nearest open cell, then walk-path in.
Local maps: RouteLegs.Hops
Inside buildings and dungeons the way is walks plus portal clicks (doors, stairs, relocate scripts). Hops is a BFS
over portal exits: reaches(a, b) = the game's tile pathfinder walks from a to b; the result is the portals to click,
in order (empty: walk straight there; null: no chain).
In the steps (player verbs that keep a route going)
walk-pathclicks ahead along the game's A* path, fights back when attacked, passes over a foe whose shots hit a wall (it is out of line of fire from here), closes stray loot windows.travel-toresumes after random encounters; refuses inside a town map (walk out first).journey <legs…>(game A) =travel-toper leg, expecting fights: an encounter's talk that offers a fight gets that option (the game's dialog data marks it: codeco), the fight is fought, combat mode is left once nothing is after the party, the leg goes on. Any other talk faults with the speaker: story talks stay scripted. The scout's route answer prints onejourneyline. Worth copying: one verb owns the whole interruption loop, so scripts never carry per-site encounter patches.- Setup cheats (no random encounters) are run state, not world state: a checkpoint must carry them, and a format change must change the cache key, or a resume quietly plays a different game (game A met ambushes for hours that way).
fightsteps two tiles along the path toward a foe it cannot hit from here.- Arrival snaps: travel near a named area lands at the area (the nearest wins), not at the clicked spot; plan the next leg from where the PC actually lands.
Pace (watchable, recordable runs)
- A long A* every poll was most of a window run's cost (game A: up to 25 ms per game frame); plan the way once per step, keep it while the PC is on it, plan again off it or after a click that did not move the PC.
- Frames per drawn frame must stay a fixed count (determinism: steps locate on what is drawn), so the wall-clock pace
swings with what each frame costs. Even it out with a hold, not a count: each game frame due at k / fps, held until
then; a lag beyond 0.1 s re-anchors instead of bursting (game A
RunPace,--pace 120; off by default). - The bigger stalls were holds: a window run that holds the game while its scene rebuilds stalls whenever something forces rebuilds — game A rebuilt the whole static scene on every critter pose frame and on travel fatigue damage (each world-map tick). Measure first (frames held per second, by cause), then key rebuilds on what the static scene actually draws and skip holds while the scene is covered (world map).
- Determinism in a window: clicks pick on the drawn scene, so which game frame was drawn last must not depend on wall time. Fixed game frames per draw (no wall-time budget), a completed step ends the batch and the next render only draws, and overlays that come and go on UI callbacks (a loading splash) must not take hits.
Gotchas met so far (game A)
- Bodies decay (a game day): loot quest items when the foe dies, not chapters later.
- Killing gatekeepers can flip a faction's reaction town-wide (a reputation), not just the witnesses.
- Off-screen objects can't be clicked: walk next to them first; the step's poll budget can run out while walking.
Gotchas met so far (game B, Source-style 3D maps)
- Dialogue effects run at the line's end, not when it starts: an NPC line's action (a quest state, a G var) lands
after its audio (a 9.8 s mp3), so wait out the line before probing or answering. Time waits from the speech file's
length (
Mp3Reader.Seconds); an oracle without audio (Wine here) ends lines early, so probe both at a fixed late time. - Map exits can start hidden: a
trigger_oncein front unhides thetrigger_changelevel0.2 s later. A teleport straight onto the exit then does nothing; land on it and wait, or walk through. - Pickups are picked by the use ray or by a near-item cone, which a teleport onto the item's spot can miss (eye inside / looking past it): stand a step beside the item and look at it.
- Map-to-map with landmarks: after a changelevel the PC lands at the landmark offset, not at the teleport spot; plan
the next leg from the
arrive <map> <origin>line. - Teleports land a few units above the floor: a spot a hair under it starts the player inside the world (every move blocked from the first frame; game B: z 36.1 vs a floor at 36.03+).
- A straight
walk-tothat stops short is often a closed door (stuck right in front of it):faceit, use, wait for it to swing, walk on. Use reach is measured from the eye (96 units in Source): walk close beforepress-use. - First-person aiming: closed loop. Send the mouse move for the remaining turn every frame and finish within a tolerance (1°); the mouse filter (m_filter) and integer pixels make one-shot turns land short.
- NPCs start talks on their own (an alley bum, a pier hawker, a guard spotting the player): the walk ends with the
talk open (game B
walk-to/walk-pathstop on a talk). Answer,wait not dialog-open, repeat the walk. - Node graphs (Source
.ain) skip stairwells, tunnels and building interiors:walk-pathto the last covered spot, then chainwalk-tolegs found with a floor scan (game Bfloor-map: floor height per grid cell under the player hull; start just above the level you want, or roofs and upper flights answer). Stairs show as steady height steps. - A hull is 32 wide: thin invisible posts and rails decide which line works. Compare with the original before calling it a collision bug (game B pier: the original's player clears a post by 0.125 units at y −1608; ours aimed at −1600 and stuck; the brush was the same).
- Gates and doors can be locked or unlocked by quest state when the map loads (game B
beachHouseOpen()on OnMapLoad): a debug scout started on that map sees a different world than the quest run. - A changelevel lying on a stair or ledge may never meet the hull when walking down (the hull rides the edge): come back up into it, or stand still inside it.
- Original movement as an oracle: teleport the PC, hold forward, sample the origin on a timer; where it stops is where your route must turn.
- Scout facts from the map data itself (entity lump: changelevel targets + landmarks,
OnStartTouch/OnPlayerPickupoutputs, the level script's quest functions, the dialogue's conditions) before trying moves in the game. - Quests with many solutions: pick the one the runner can do today and cheat the PC into it in setup (game B
cheat vstats get charisma 5: the game's own console cheats, so the rest of the run stays player mode). Dialogue checks hide failed replies:answer ncounts the shown ones, so a stat change renumbers them. - Teleport lands next frame: wait a frame or two before a verb that reads the player's position (path planning).
- Doors rotate about their origin (the hinge): aim use at the leaf's middle. A door can be locked from one side only (game B: the knob on the player's side carries the difficulty): scout the side, not just the door.
- When a node graph routes through a door the runner cannot open, that door is the designers' intended way: find what opens it (lockpick, key, quest state) before hunting for another path.
- Watching runs: eased turns look right but change routing: walking while easing round a point closer than the turn radius circles it (stuck fault). Hold forward only when facing the point (tighter when it is near), let go at sharp corners. Run the runner in lockstep with the window's frame loop (one runner frame per drawn frame, each side waits for the other) so the world is never touched from two threads and digests match headless.