I love watching Stardew Valley challenges. How fast can you unlock Ginger Island; most gold you can make without leaving the farm!
I occasionally get the urge to try it out myself. But I can’t sink that much time into the game and, to be honest, I wouldn’t have the patience for it either. But still the urge persists.
So I decided to upskill myself and delegate the work to an AI. This isn’t a ChatGPT plays Stardew experiment: no, I will be coding up my own traditional game AI system to execute my vision.
I’ve been meaning to learn more about these approaches and it turns out that writing your own in a existing game is quite illuminating.
The Basic Architecture
The first hurdle we have to overcome is interface with the actual game. We need some way of extracting information from the game and, once we’ve decided what to do with that information, some way of executing actions within the game.
Computer vision is one approach but it’s a whole ordeal in itself. Fortunately for us there’s an alternative: the Stardew Modding API SMAPI. This exposes the game engine itself which we can use to provide the relevant information.
So I code up a simple wrapper mod which reads information from the game engine and provides it as a JSON payload over TCP to our planner bot. The planner bot then responds with a limited set of options which our mod then enacts.
On the SMAPI side this is a little complicated; but it at least lets us simply things on the Julia side. We initialize with
using StardewBot
conn = connect_bot()
state = request_info(conn)
The mod then sends back a snapshot of the game state containing
- the time of day
- the player’s gold
- the player’s current location
- the player’s stamina
- the player’s toolbar and inventory
- the current map including item locations and warp tiles
As we add more features this will continue to grow: for instance we’ll need to add some fishing minigame specific information when we tackle that.
On the Julia side we need to process that information and respond with one of the following commands:
request_infoessentially waits and gets a new snapshotnavigate x ywalk to a tilenavigate_to_warp locationwalk to the warp tile leading to a locationselect_tool indexequips a toolbar slot — equip a toolbar slot (0-based)use_tool x ywalk_up / walk_down / walk_left / walk_rightmove one tick in the appropriate direction
We’ll add some actions as well as we expand.
Upon completion the mod sends the state back and the loop continues.
Challenge 1: Clearing out the Farm
Decision Trees
We’ll use a decision tree to put some intelligence here:
are we inside -> go outside or continue is it past 11pm -> go to bed or continue do we have energy -> clean closest debris or go to bed
This simple algorithm can iterate blindly and in the course of a couple days will clear the farm. You can imagine we make this more and more complex, but the cost of that complexity is going to overwhelm us. Consider us trying to add watering crops: the depth of the tree is going to be insane. We need a better way of representing this tree (graph actually, you probably want shared subtrees). One potential way is to introduce state.
do we have chores done -> do chores and set chores=done or do the rest.
This makes things simpler in some respect but now we need to consider state as well.
State Machines
It’s easier to represent this behavior as a State machine. We have states: a particular behavior that repeated and transition between states. We can represent our state easily:
We run into a problem. We have a list of redundant checks for night time. Hierarchical state machines let us simplify this by pulling state machine into a hierarchy.
We are already effectively doing this within the sleep code. Conceptually these states are bed and then sleep.
using StardewBot
include("julia/src/FarmFSM.jl")
using .FarmFSM
conn = connect_bot()
run_bot(conn, "Bot")
function get_obstacles(state)
obstacles = Obstacle[]
for obj in state.objects
if obj.name == "Stone"
push!(obstacles, Obstacle(obj.x, obj.y, Stone))
elseif obj.name == "Weeds"
push!(obstacles, Obstacle(obj.x, obj.y, Weeds))
elseif obj.name == "Twig"
push!(obstacles, Obstacle(obj.x, obj.y, Twig))
end
end
for t in state.terrain
t.type == "Tree" && push!(obstacles, Obstacle(t.x, t.y, Tree))
end
return obstacles
end
@enum FarmBotState InFarmHouse ClearingDebris OutOfEnergy Sleeping
const MIN_STAMINA = 5 # go to sleep when stamina falls to this level
function step(conn, state, ::Val{ClearingDebris}, skipped)
String(state.location) != "Farm" && return state, InFarmHouse
state.player.stamina <= MIN_STAMINA && return state, OutOfEnergy
obstacles = filter(o -> (o.x, o.y) ∉ skipped, get_obstacles(state))
isempty(obstacles) && return state, OutOfEnergy
obs = argmin(o -> tile_dist(state, o.x, o.y), obstacles)
new_state = clear_obstacle(conn, state, obs)
obstacle_present(new_state, obs.x, obs.y) && push!(skipped, (obs.x, obs.y))
(new_state, ClearingDebris)
end
function run_bot(conn::BotConnection, save_name::AbstractString)
state = load_save(conn, save_name)
fsm = InFarmHouse
skipped = Set{Tuple{Int,Int}}()
while true
println("[$fsm]")
state, fsm = step(conn, state, Val(fsm), skipped)
end
end