Hot Reload
Hot reload is not a bolt-on file watcher. It is what redefinition means in JaiScript. Executing a class Foo { ... } declaration when Foo already exists redefines the class and migrates every live instance in place. Executing a function f(...) declaration rebinds the global. The host's job is only to re-run the new source (engine->execute(newSource) / script-side include); the engine does the rest. The design promise: the reload always succeeds. Iteration speed is the point, an edit never bricks a running session.
The workflow at a glance
- Split rules from state. Put functions, classes and tables in modules you
import; keep the long-lived state (the world, the player, the RNG) in variables declared without an initializer (World world;,int kills;) or in a file nothing re-runs. - Reach the imports every frame. The host calls a script function each tick, and that function names its modules (
import("rules.jai");). An unchanged file costs a stamp check at most everyimport_recheck_interval(0.2 s by default); the host loop below is the whole C++ side. - Edit and save. Within about the interval the next frame re-runs the changed module: functions and classes replace in place, live instances migrate, initializer-less variables keep their values.
- Convert what changed shape. A class's
void hot_reload_migrate()runs per live instance after a field change; a file'svoid hot_reload_migrate(map<string, var> previous)runs after the file re-runs, with its old variables (below). - Break it safely. A broken edit is parsed and checked before anything runs: the call reports the listing in its
checked_result, the program keeps the code it had, and the next save tries again.
The standalone runner's jaiscript --watch file.jai re-runs a file on every save in a fresh engine, which suits a script that prints; state that survives an edit needs the import loop above.
Instance migration rules
One engine, two executes:
eng->execute(R"( class Player { int hp = 100; float speed = 1.0; string name = "Unnamed"; }
var p = Player(); p.hp = 62; p.name = "Alice"; )");
eng->execute(R"( class Player {
string hp = "0"; // retyped
string name = "Unnamed"; // kept
int level = 1; // new
// speed removed
void hot_reload_migrate() { level = 5; }
} )");
eng->execute(R"( print("hp: ", type_of(p.hp), " ", p.hp);
print("name: ", p.name);
print("level: ", p.level);
try { print(p.speed); } catch (e) { print("speed: ", e); } )");hp: string 62
name: Alice
level: 5
speed: Player has no member 'speed'The retyped hp converted (int 62 to "62"), the same-name name kept its runtime value, the new level took its initializer and then the hook ran, and the removed speed is gone.
The full matrix. Both backends share one field-migration kernel, so behavior is identical, and the test suite holds them to it:
| change | what happens to live instances |
|---|---|
| field kept (same name, same type) | value preserved, even if the initializer changed |
| field added | takes the new initializer |
| field removed | gone; access raises Player has no member 'speed' |
| field retyped | value converts permissively via the assignment table (int→string via to_string, float→int truncation, int→float widening); only a value with NO conversion falls back to the new initializer. A reload never bricks an instance |
| method body changed | next call runs the new body, including through a method bound earlier with reflect::bind (no stale caches) |
| static members | runtime state preserved, not reset to the initializer; removed statics inaccessible; static method bodies update |
| constructor overloads | survive (arity dispatch intact) |
| base class reloaded | derived instances keep their own data, inherit new/retyped base fields (converted through the parent chain) |
| script class extending a C++ class | the live C++ object is carried across, neither destructed nor rebuilt; inherited C++ methods stay callable |
Migration cost is linear in live instances and fields; 100 instances migrate fast enough to sit in a keypress handler.
An initializer in the source describes a new instance's default; it does not rewrite a surviving field in a live instance. Use the existing migration hooks when a schema changes, or an explicit update function when applying a new default to current objects is intentional. The unit-level hook below can also update retained state after a file re-run. There is no need for a second state migration API.
The hot_reload_migrate hook
If the new class defines void hot_reload_migrate(), it runs once per live instance during a reload that changes the class's fields (adds, removes or retypes one), after field migration. A reload that changes only method bodies or initializers migrates nothing and runs no hook. Inside the hook the old removed fields are still readable, which is what makes real data conversions possible.
Start with the shipped class:
class Config {
int version = 1;
int flags = 42;
}Now the edited one. flags is gone from the declaration, but the hook still reads it. That is the whole point:
class Config {
int version = 2;
var settings = {}; // new field
// flags removed
void hot_reload_migrate() {
[[warn_mode]]
if (this.flags == 42) { // the OLD field, still readable here
this.settings = {"debug": true, "level": 5};
}
}
}And the reload itself, a second execute on the same engine, which is all include is:
include "config_v1.jai";
var c = Config();
include "config_v2.jai"; // the reload: c migrates in place
print(c.version); // kept its runtime value
print(c.settings["level"]); // the hook read the removed field1
5version printed 1, not 2: a field that keeps its name and type keeps
its runtime value, and the new initializer only applies to instances built after the reload. The new
settings field took its initializer, then the hook filled it from the field that no longer
exists.
Why the [[warn_mode]]. Static checking is strict, and this.flags is not a member of the class you just declared. The checker does open
old shapes for a redefinition: when the engine already holds the previous Config at
check time (the two-execute host flow above), a reloaded class may read the fields still live in it. But
a fresh engine has no previous shape, and neither does this program: both includes belong to one unit, which
is checked in full before its first statement runs, so v1 is
not live yet when v2 is checked. Without the attribute the same text is a load error: no member
'flags' in class 'Config'. Since migration hooks are exactly the code you want to survive
both situations, put [[warn_mode]] in front of the statement that reads removed fields (or, as
here, at the top of the hook's body). It demotes the diagnostic for that subtree and changes nothing at
run time. (See Escape hatches.)
Chained across versions v1→v2→v3→... each reload runs only the incoming class's hook. Robustness rules: an error in one instance's hook doesn't abort migration of the others (that instance keeps the fields migration gave it), and an instance a hook constructs is already of the new class, so it is not migrated again.
A file's reload hook
Program-scope variables get the same treatment at file level. A file that declares void hot_reload_migrate(map<string, var> previous) at its top level has that hook run once after each re-run of the file (an import reload, the host's execute_file of the same path, the same source string executed again, a top-level include run again by the same unit), after the re-run's top-level code, never on the first load. previous maps every program-scope variable the file declared to its value from before the re-run, removed, retyped and re-initialized ones included.
The hook lives on the file's unit, not in the program: it is not registered as a global, so neither script nor host code can call it, another file's hook is never reachable, and two files each keep their own. Each run takes the hook from its own text. The checker types its signature (a wrong one is a load error spelling the required form) and reserves the name at a file's top level only. An exception thrown by the hook surfaces like a run-time error in the re-run, catchable by the code that reached the import, with no rollback: the program stays as the re-run left it. A file whose text declares no hook pays nothing; the snapshot is taken only when the new text declares one. A jaibite keeps the hook off its program and saves it, but runs none.
Code on the live call stack
Redefining a function (or class) while it is executing (e.g. a host callback fires mid-script and re-executes a script file) follows one rule: frames already on the stack finish with their old bodies; the global binding is replaced immediately, so recursion and all later calls resolve the new one; the new definition persists.
Here is a function that swaps itself out mid-call by importing a file that redefines it, then calls itself once more before returning:
function damage(int x) -> int {
print("v1 body: damage({})", x);
import "damage_v2.jai"; // program scope: swaps the global binding while this frame is live
if (x > 1) { print("recursive call, new body: {}", damage(1)); }
return x; // this frame finishes on the old body
}
print("outer call, old body: {}", damage(10));
print("later call, new body: {}", damage(10));with damage_v2.jai:
function damage(int x) -> int { return x * 3; }v1 body: damage(10)
recursive call, new body: 3
outer call, old body: 10
later call, new body: 30The frame that was running when the swap happened finished on the old body (10); the recursive call inside it already resolved the new one (3); the new binding survived (30). The same rule covers a class reloaded while one of its methods is on the stack, and reentrant execute() calls, which neither clobber the suspended outer run's environment nor leak exceptions into it. Coroutines suspended at reload keep their old bodies until rebuilt.
The swap uses import because an import's effect is always program scope. A literal include inside a function body is spliced into that body when it parses: the functions it declares become the enclosing function's own, so they never replace the global one (classes it declares still replace the global class). Reload from program scope, from the host, or through import.
Live usage: the games' --dev modes
Two of the shipped examples dogfood this:
examples/crawler(--dev, keyR):reload_scripts()inscripts/main.jaire-runs every rule file mid-game and deliberately skipsstate.jai, which holds the persistent globals (G,RNG). The pattern to copy: split rules (reloadable) from state (not re-executed).examples/demoreel: live-coding. Edit a scene file while it renders and pressr: the host re-executes every scene file into both engines and the current scene rebuilds;main.jai, which owns the persistent globals, is never reloaded.
include re-runs a file every time by design (PHP-style template semantics): the forcing form. import is the module system, and reload is pull-based: a module re-runs when an import of it is reached after its file changed. There is no watcher thread and no out-of-band reload sweep, deliberately: a module re-running has side effects, and those land exactly where control flow puts them. The full rules are the guide's Reloading from a running program; what a host needs from them:
- Checks come in passes. At most once per
import_recheck_interval(0.2 s by default) the first import reached opens a pass and every import reached in it stats its modules once; between passes an import reads no clock and asks the filesystem nothing. A touched file is picked up within about the interval, and a frame sees all of a set of edits or none. - Check, then apply. A changed module is parsed and checked (with the modules it imports) before any of it runs. A mistake raises a catchable error naming the file and line, and the program keeps the code it had. The broken file is not retried until it changes again.
- A module is its whole tree. A pass also checks every module a module's last run imported (transitively, each once per pass) and every file that run included, so an edit to a nested module or to an included table file re-runs exactly the module that read it.
- A missing file raises once. When a loaded module's file disappears, the next pass raises
Failed to open import file: <path>once; after that the program keeps the code it had and the import waits quietly until the file is back, then re-runs it. - State survives by declaration. Functions and classes replace in place (live instances migrate, rules above); a variable declared with an initializer takes the new value, one declared without (
int kills;) keeps the value it holds. - The call form is an expression.
import("rules.jai")answerstruewhen a module ran on this reach;import("a.jai", "b.jai")or an array of paths is one group decision; a trailingjai::forceopens a pass at once (an F5 key).
The host side is a frame loop that reaches the imports, usually by calling a script function that names them:
import "rules.jai";
int tick(int frame) {
import("rules.jai"); // reached every frame: a touched rules.jai re-runs here
return damage_for(frame);
}// compiled and run against the library
eng->execute_file("scripts/game.jai");
jai::script_value tick = eng->get_variable("tick");
for (int64_t frame = 0; running; ++frame) {
auto r = tick.as_function()({eng->make_value(frame)});
if (!r) { log(eng->format_error(r)); } // "Division by zero", a broken edit's listing, a missing module, an uncaught throw
else { use(r.value().as_int()); }
// ... render, sleep ...
}Every failure comes back in the checked_result: a run-time error inside the call, a rejected reload (the check listing, naming rules.jai with its line and a caret), a module whose file went missing, and a script throw nothing caught. The engine keeps the code it had and the next frame runs it. A throwing call (execute, a convert_script_function wrapper) raises the same failures as exceptions (Error surfaces). Inside script, a try around the import catches the same errors.
Tests want no window at all: eng->import_recheck_interval(0) (or import_recheck_interval(0) from script) checks on every reach, so a file the test writes is seen by the very next import, even inside one execute(). With the default 0.2 s, an edit made just after a pass is seen by the first reach after the window closes.
A file is read under its stamp, its modification time and size: the stamp queried after the read must match the one taken before it, or a save landed during the read and the text may mix two versions. Such a read never re-runs a module that ran before (the next pass reads it whole), and a first load reads again at once. eng->file_stamp_source(fn) replaces where stamps come from for execute_file, include, import and their caches: fn(path) returns a jai::engine::file_stamp{mtime, size}, or std::nullopt for a file it cannot stamp, and an empty source selects the file system's. A host serving scripts from a pack supplies its own, and so does a test that wants a save to land during a read on cue.
engine::reset_import(path) and reset_imports() are the narrow escape hatches (forcing a re-run under once mode), not the mechanism; set_import_behavior(jai::engine::import_behavior::once) picks the policy (file_timestamp, the default; once; or always, which treats every import as an include).
Reload reports
A host (an editor panel, a log line after each reload) can ask what a reload did to live instances. Reporting is off by default; turned on, every automatic field operation a reload performs is aggregated into rows until the host clears them. Continuing the Player reload above:
// compiled and run against the library
const char* outcome_name(jai::field_migration_outcome o) {
switch (o) {
case jai::field_migration_outcome::preserved: return "preserved";
case jai::field_migration_outcome::converted: return "converted";
case jai::field_migration_outcome::reset: return "reset";
case jai::field_migration_outcome::added: return "added";
case jai::field_migration_outcome::removed: return "removed";
}
return "?";
}
eng->hot_reload_reporting(true); // off by default
eng->clear_hot_reload_reports(); // rows accumulate until cleared
eng->execute(new_player_source); // the reload
for (const jai::field_migration_report& row : eng->hot_reload_reports()) {
std::cout << row.class_name << "." << row.field_name << ": " << row.old_type << " -> " << row.new_type
<< " " << outcome_name(row.outcome) << " x" << row.count << "\n";
}Player.hp: int -> string converted x1
Player.name: string -> string preserved x1
Player.level: <absent> -> int added x1
Player.speed: float -> <absent> removed x1A row aggregates by class, field, the stored type before and after, and the outcome (preserved, converted, reset when a retyped value had no conversion and took the new initializer, added, removed); count is the number of instances that field operation touched. A reload that changes no fields migrates nothing and adds no rows. Rows hold metadata only: no values, no object identities, no hook outcomes. The host reads them on the engine's thread after the reload; nothing calls back during migration, and with reporting off a reload records nothing.
Interaction with the rest of the engine
- Source cache / static checker: new source text is a new parse-cache entry, so the checker re-checks reloaded code automatically. A redefinition may read fields of the shape the engine still holds (that is what makes migration hooks legal). The same text in a fresh engine is a load error.
- Parse-time type proofs: a reload that retypes, drops, re-parents or hides a field re-stamps every entry checked against the old shape at its next execute, and each lane a proof selects keeps its runtime guard, so the value converts through the migration path documented above. Nothing for you to do. It is why the fast lanes stay safe under reload (Proofs).
- jaibite: class redefinition works across repeated bite executions too.
- Serialization: for C++-side class evolution, versioned archives +
post_load_hookare the analogous migration story (see Serialization chapter);hot_reload_migrateis the script-side twin for live sessions.