Quick mental model
What you write
- An NPC has a
scheduleTemplate.
-
The template contains
rules (what the NPC wants to do)
and optional movement settings.
- Rules generate "visit intents" for a week.
-
The scheduler turns intents into a continuous timeline with travel
time between locations.
What the scheduler guarantees
-
Schedule is generated as a week:
Monday 00:00 → next Monday 00:00 (UTC-based).
- Slots never overlap; travel is explicit (walk/bus/car).
-
Rules with higher built-in priority win conflicts (
fixed
beats random, etc.).
Times are UTC. Rule windows use
Date.getUTCHours()/getUTCMinutes() internally. Use 24h
strings like "06:00", "22:30", and
"24:00".
Top-level structure
{
// NPC-level schedule config:
scheduleTemplate: {
// Optional: season whitelist. If set, NPC has NO schedule when current season is not included.
season: [Season.SUMMER, Season.AUTUMN],
// Optional: walking speed modifier. 1 = normal, 1.5 = slower, 0.8 = faster.
travelModifier: 1,
// Optional: transport preferences (bus/car).
transport: {
bus: { use: true, window: { from: "06:00", to: "22:00" }, fromDuration: 0 },
car: { use: false, window: { from: "00:00", to: "24:00" }, fromDuration: 10 },
},
// Required: the schedule rules.
rules: [ /* ...rules... */ ],
}
}
| Field |
Required? |
Meaning |
scheduleTemplate.rules |
required |
Array of rule objects. If missing/empty, scheduler produces no
schedule.
|
scheduleTemplate.season |
optional |
Season whitelist. If provided and the current season is not
included, the scheduler returns an empty schedule.
|
scheduleTemplate.travelModifier |
optional |
Walking speed multiplier (only affects walking segments). Must be a
positive number. Default: 1.
|
scheduleTemplate.transport |
optional |
Controls whether bus/car are considered, and when. |
Transport config fields
| Field |
Required? |
Meaning |
transport.bus.use, transport.car.use |
optional |
Enable/disable that transport mode. Default: false when
omitted.
|
transport.*.window |
optional |
Daily UTC time window where this transport mode is allowed. Missing
window means "allowed at all times". Supports overnight windows
(e.g. 22:00 → 05:00).
|
transport.*.fromDuration |
optional |
Minimum trip length (in minutes) before this transport mode is
allowed. If missing or ≤ 0, the mode is allowed regardless of trip
length. It compares against the computed walking trip time
(including micro-lingers).
|
The scheduler decides between walk/bus/car dynamically based on environment
(night/cold/bad weather), route complexity, and relative speed. Even if
use: true, the mode might not be chosen.
Rule types
rule.type must be one of the SCHEDULE_RULES values.
Currently handled by the scheduler: home, fixed,
random, daily, weekly.
follow (and any unknown types) are ignored.
| Type |
What it does |
Typical use |
SCHEDULE_RULES.home |
Creates hard "be at home" intents for specific
timeBlocks on every day.
|
Sleep, long at-home blocks. |
SCHEDULE_RULES.fixed |
Creates a hard intent occupying the entire window. |
School, work shifts, standing appointments. |
SCHEDULE_RULES.random |
Creates multiple "soft" intents within a window, each
picking a target from your targets list. Actual number
is estimated from window length and average stay.
|
Errands, wandering, "fill time with activities". |
SCHEDULE_RULES.daily |
Creates at most one "soft" intent per eligible day within the
window.
|
"Once per day" routine (gym, café, walk). |
SCHEDULE_RULES.weekly |
Picks one eligible day per week, then creates a single intent within
the window.
|
Weekly events (bar night, therapy, market day). |
"Hard" vs "soft": home and fixed are treated as hard
anchors when resolving conflicts. Soft intents (random/daily/weekly) will be
shortened/shifted around to avoid missing hard anchors.
Rule object: fields
Every rule is a plain object. Unknown fields are ignored. Below are the fields
the scheduler actually reads.
| Field |
Required? |
Applies to |
Meaning |
id |
optional |
All |
String ID used for debugging and for linking schedule slots back to
the rule. Recommended to keep stable.
|
type |
required |
All |
One of SCHEDULE_RULES.*. |
dayKinds |
optional |
All except home |
Limits rule to specific day kinds (from the calendar):
DayKind.WORKDAY, DayKind.DAY_OFF. Can be a
single value or an array.
|
daysOfWeek |
optional |
All except home |
Limits rule to specific weekdays. Use DAY_KEYS values:
"sun", "mon", "tue",
"wed", "thu", "fri",
"sat". Can be a single value or an array.
|
candidateDays |
optional
alias
|
All except home |
Alias for daysOfWeek. If both are present,
daysOfWeek wins.
|
probability |
optional |
All |
Chance the rule runs.
undefined/null → always
- ≤ 0 → never
- ≥ 1 → always
- Between 0–1 → RNG check
For weekly, this check happens once per week. For other
types, once per eligible day.
|
timeBlocks |
required (for home) |
home |
Array of {from,to} blocks in a day where NPC must be
home. cannot cross midnight (if
to ≤ from the block is ignored). Split overnight sleep
into two blocks instead.
|
window |
required (for non-home rule types)
|
fixed, random, daily,
weekly
|
A daily time window {from:"HH:MM", to:"HH:MM"}.
Overnight windows are allowed (e.g. 22:00 → 03:00).
|
stayMinutes |
optional |
random, daily, weekly (also
read by some helpers)
|
Controls how long the NPC stays per visit.
stayMinutes: { min: 20, max: 120, round: 10 }
Defaults if missing:
min = 30, max = min.
round rounds stay to nearest multiple (minutes), then
clamps to fit the window and hard anchors.
|
targets |
required (for non-home rule types)
|
fixed, random, daily,
weekly
|
Array of target entries (see next section).
Must be an array — a single object
will be treated as "no targets".
|
nearest |
optional |
fixed, random, daily,
weekly
|
Rule-level nearest: if true, the scheduler picks the
nearest candidate across all targets combined (instead of
randomizing per-target group).
|
respectOpeningHours |
optional |
Any rule using place targets |
If true, candidate places must be open at the start of
the window and still open at (window end − 1 minute). This is a
coarse filter: it does not split windows around opening hours.
|
respectAgeRestriction |
optional |
Any rule using place targets |
Default: true. When true (or absent), places with
place.props.ages gates are filtered out if the NPC's
age is below ages.min or above
ages.max. When set to false, the NPC can
target age-gated places anyway.
|
disallowedTargets |
optional |
Any rule using targets |
Optional "forbidden pool" filter applied when resolving
targets. Same shape as target entries, but only
supports home, placeKeys, and
placeCategory. Any other types are logged to the
console and ignored.
|
If your window is shorter than stayMinutes.min, the
rule produces no intents. For fixed, stayMinutes is ignored (it
always occupies the full window).
Targets
A rule’s targets is an array of "target entries". Each entry
expands into a pool of concrete places across the world. The scheduler then
picks from that pool (randomly or by nearest, depending on settings).
Supported target types
| Target type |
Required fields |
Meaning |
TARGET_TYPE.home |
none |
Targets the NPC’s home location. |
TARGET_TYPE.placeKeys |
candidates (string or array) |
Selects any place whose place.key matches one of the
candidates.
|
TARGET_TYPE.placeCategory |
candidates (tag or array) |
Selects any place whose place.props.category contains
one of the candidates.
|
The scheduler currently does not resolve
TARGET_TYPE.npc, TARGET_TYPE.player, or
TARGET_TYPE.unavailable into real locations. If used in
targets, they produce no candidates.
Target entry fields
| Field |
Required? |
Meaning |
type |
required |
One of the supported target types above. |
candidates |
required (for place targets) |
For placeKeys / placeCategory: a
string/tag or array. Missing/empty means that target entry
contributes no candidates.
|
nearest |
optional |
If true, the scheduler chooses the nearest place among the
candidates for this entry (based on current location at
scheduling time). If omitted/false, a random candidate is chosen
from this entry’s pool.
|
stay |
optional |
If true, the visit will try to consume the maximum possible stay
time for the intent (instead of picking a random duration between
min and max). Works best when
stayMinutes.max is large enough to fill the window.
|
Legacy fields (supported but prefer candidates)
Target entries also accept legacy fields for backwards compatibility:
-
key / keys (as a string or array) for
placeKeys
-
categories (tag or array) for placeCategory
If
candidates is present, it is used in preference to legacy
fields.
disallowedTargets (forbidden pool) details
disallowedTargets is optional and is applied after targets
are expanded into concrete candidates, but before the scheduler chooses which
place to go to.
disallowedTargets: [
{ type: TARGET_TYPE.placeKeys, candidates: ["mall"] },
{ type: TARGET_TYPE.placeCategory, candidates: [PLACE_TAGS.nightlife, PLACE_TAGS.luxury] },
{ type: TARGET_TYPE.home } // prevents choosing home when home is included as a target
]
-
placeKeys removes any place whose
place.key matches.
-
placeCategory removes any place whose
place.props.category intersects the forbidden tags.
-
home removes the home location candidate (only matters if you
include home in targets).
Only home, placeKeys, placeCategory are
valid in disallowedTargets. Any other type is
ignored and will print a console warning.
Examples
1) Sleep at home (home rule)
{
id: "sleep_at_home",
type: SCHEDULE_RULES.home,
// Required for home: timeBlocks (must not cross midnight)
timeBlocks: [
{ from: "00:00", to: "06:00" },
{ from: "22:00", to: "24:00" },
],
}
2) Fixed appointment (fixed rule)
{
id: "attend_school",
type: SCHEDULE_RULES.fixed,
dayKinds: [DayKind.WORKDAY],
daysOfWeek: ["mon","tue","wed","thu","fri"],
window: { from: "09:00", to: "15:00" },
targets: [
{ type: TARGET_TYPE.placeKeys, candidates: ["high_school"], nearest: true },
],
// Optional filters:
respectOpeningHours: true,
respectAgeRestriction: true,
}
3) Random errands with forbidden pool
{
id: "before_school_morning",
type: SCHEDULE_RULES.random,
dayKinds: [DayKind.WORKDAY],
window: { from: "06:00", to: "09:00" },
stayMinutes: { min: 20, max: 120, round: 10 },
targets: [
{ type: TARGET_TYPE.placeKeys, candidates: ["library"] },
{ type: TARGET_TYPE.placeKeys, candidates: ["high_school"], nearest: true, stay: true },
{ type: TARGET_TYPE.placeCategory, candidates: [PLACE_TAGS.leisure, PLACE_TAGS.food] },
{ type: TARGET_TYPE.home },
],
disallowedTargets: [
{ type: TARGET_TYPE.placeKeys, candidates: ["mall"] },
{ type: TARGET_TYPE.placeCategory, candidates: [PLACE_TAGS.nightlife, PLACE_TAGS.luxury] },
],
respectOpeningHours: true,
}
4) Age-gated targets: override restriction per rule
{
id: "juvenile_break_in",
type: SCHEDULE_RULES.daily,
window: { from: "18:00", to: "20:00" },
stayMinutes: { min: 20, max: 60 },
targets: [
{ type: TARGET_TYPE.placeKeys, candidates: ["jail"] }
],
// Default is true; set to false to ignore place.props.ages.min/max
respectAgeRestriction: false,
}
Common pitfalls
-
Targets must be an array. A single object
targets: {...} will be treated as no targets.
-
Home timeBlocks can’t cross midnight. Split into two blocks
instead.
-
Opening hours filter is coarse. If a place closes
mid-window, it’s excluded entirely (when
respectOpeningHours is
true).
-
Age restriction defaults on. If an NPC is under
ages.min (or over ages.max), that place is
excluded unless respectAgeRestriction: false.
-
Unknown rule/target types are ignored. If something "does
nothing", check the console and verify you used a supported type.