fleabitdev / glsp

The GameLisp scripting language

Home Page:https://gamelisp.rs/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Call for suggestions: Hotloading

fleabitdev opened this issue · comments

Opening this issue to gather suggestions for how best to implement hotloading (that is: editing a game's source code, saving it, and seeing the changes reflected in a running game without needing to restart it).

GameLisp is extremely late-bound, which is both a blessing and a curse in this case. A blessing, because a lot of code should "just work" if we tweak bind-global! slightly and then run load again with the modified source file. A curse, because the user is free to make changes which have ridiculous knock-on effects, like redefining the defclass macro or the load function, or storing a reference to a class in a local variable, which is captured by a closure, which is stored in a Lib...

I'm struggling to decide whether to aim for a universal, highly-correct solution, or aim for a much simpler solution which will unpredictably fail sometimes. I chose the second option when designing compilation, and I'm quite happy with the result.

It would be easy enough to detect when a hot load call attempts to do something more complicated than calling bind-global!, in which case we could emit a warning, or fall back to reloading the entire source tree, or force a restart. In other words, even if naive hot-loading fails 5% of the time, we could make it a graceful failure rather than an unpleasant, confusing one.

I'm considering two options for dealing with dangling references:

  • Use heuristics to guess when a new Class, GFn or quote shares the identity of an existing one, in which case it should be mutated rather than being duplicated.
  • When a global variable or a toplevel let is redefined while hotloading, brute-force scan the entire heap for dangling references and change them to point to the new definition.

Also relevant: #8, #9

@baszalmstra, one of the developers of Mun, suggested that I look into how hot reloading of games is handled in the Lua ecosystem. I've found some very useful information there.

Similar to my thoughts above, it looks like hot reloading in Lua is usually achieved by simply re-running a source file to re-initialize various globals. Because Lua is late-bound, this tends to work very well without any special intervention from the programmer. There are three common corner cases:

  • Toplevel code will run again when its source file is hotloaded. The solution is to either avoid toplevel code altogether (which is already encouraged in GameLisp so that compilation is more likely to succeed), or guard the toplevel code with some kind of hot-loading? predicate.

  • Global variables will be reset to their default value. The pattern for avoiding this in Lua is global_name = global_name or initializer_expr, so that initializer_expr is only run once. Writing an equivalent macro in GameLisp would be trivial.

  • The require function will refuse to load the same source file twice, so it needs to be manually reset before hotloading. The same will be true for GameLisp.

I can think of four additional corner cases in GameLisp which aren't present in Lua:

  • GameLisp is more strict about redefining existing global variables. This leads to a dilemma: should we give GameLisp an explicit "hotloading mode", or should we just recommend redefining the bind-global! function to be more permissive during hotloading?

  • Macros represent a very pervasive form of "toplevel code execution". I think I'll probably have to recommend hotloading GameLisp code by re-running the entire source tree, rather than running individual files. It's inelegant, and it will introduce an awkward few-hundred-milliseconds pause during hotloading, but it will make correctness easier to achieve.

  • GameLisp has explicit classes, whereas Lua doesn't. (This is actually an advantage for GameLisp - hotloading the definition of a Lua "class" wouldn't necessarily work very well.) We'll need to figure out how to modify a class without reallocating each individual object. For example, when a new field is added to a class, all extant objects of that class will need to enlarge their storage slightly, without overwriting or reordering existing fields.

  • Mixins aren't late-bound. When a mixin is modified, each class which incorporates that mixin will also need to be recompiled. This is another problem which would be solved "for free" if we reload the entire source tree, rather than reloading individual files.

Finally, small syntax errors while editing code are so common that I would consider error-recovery to be an essential feature. However, if an error interrupts hot-reloading partway through reloading a game's source tree, the codebase might be left in an incoherent state. I can see two possible solutions: (1) cache all globals and restore their old values if an error occurs, or (2) encourage the programmer to pause the game and display a "please try again" prompt when an error occurs during hotloading.

A curse, because the user is free to make changes which have ridiculous knock-on effects, like redefining the defclass macro or the load function

Common Lisp user here, explaining how we approach that issue.

The CL standard explicily states that trying to un/redefine any of the built-in standard functionality results in undefined behavior - so the implementation is allowed to permit this, signal an error, break in subtle ways, or outright crash. I think that this approach works well for CL as a language that is meant to empower the programmer: if the user is brave enough to redefine built-in functionality, they better know what they are doing, since they are on their own at this moment.

As for load-time object identity, Common Lisp implementations also handle the issue of loading compiled files (called FASLs in the CL world) and resolving load-time references to other objects. I'm not very knowledgeable on that topic. This part is not fully standardized and each implementation does it slightly differently; perhaps other lispers will be able to provide more information, if required.

I've had a great deal of success with hotloading using GameLisp as it exists right now, by simply returning an arr of classes from my entry.glsp, and then having entities in my game which supply the name of the class they'd like to dictate their behavior. If a change in the files are detected, the code is re-evaluated, and a new list of classes is returned. The classes are defined locally using let-class, so simply calling glsp::load("entry.glsp") is enough to get completely fresh versions. I avoid globals and def* entirely. Instances of objects which have behavior dictated by their class hold a Root<Obj> which points to an instance of their class. When the entry.glsp is reloaded, entities which have classes associated with their behavior are iterated through, and if a class with the same name can be found, a new instance of it is associated with the entity. (Though classes can override this behavior with their meth reload, which is passed the new class and returns an instance of it to replace the instance of the old class that's associated with the entity.)

(let-class Inchworm
    (field heading)

    (init (ent)
        (= @heading (rand-vec2 1.0 1.0)))

    (const static-update (fn ()
        (when (< 0.001 (rand 1.0))
            (let plant (rand-select ..(instances-of 'GrassClump))
                 new-worm (spawn-instance 'Inchworm))
            (= [new-worm 'pos] [plant 'pos]))))

    (meth update (ent)
        (= @heading (.norm (.+ (rand-vec2 0.1 0.1) @heading)))
        (.move ent (.* @heading 0.001))))