OOP Ideas for Screeps/JS



  • What are folks doing to integrate some OOP design into their Screeps code?

    Background: I'm an experienced systems programmer but I'm brand new to Javascript, and it's not immediately clear to me how to integrate JS classes efficiently with the existing Screeps classes. I've been playing around in the console a bit and it seems like we can't just write class MyCreep extends Creep { ... } sadly, which is exactly what I want to do.

    I've been thinking about this in the back of my mind for a few days and I think a good construct would be class MyCreep { constructor(creep_id) { ... } } where all the properties are just setters/getters to/from the creep's memory, then wherever I deal with MyCreep objects if I want to invoke a true Creep method (moveTo, etc...) I'll just have to go through a special method/property like MyCreep.creep which would return the true Creep. Kind of like this:

    class MyCreep {
      creep;
      constructor(creep_id) { this.creep = Game.getObjectById(creep_id); }
    
      get memory() { return this.creep.memory; }
      get target() { return Game.getObjectById(this.memory.target_id); }
      set target(target_object) { this.memory.target_id = target_object.id; }
    
      myMoveTo(target_object) {
        this.target = target_object;
        // other state adjustments...
    }
    

    Then I could use them like this:

    var john = new MyCreep(Game.creeps['John Galt'].id);
    john.myMoveTo(john.creep.room.controller);
    

    My biggest concern with this method is that MyCreep objects require the apparently superfluous creep member, and I have to actually allocate them with new - I would have to resort to _.forEach(Game.creeps, (creep) => {new MyCreep(creep.id)}) each tick. The systems side of me is triggered by the dynamic allocations incurred by the calls to new MyCreep(). At a high level there is no real extra state I need to capture that isn't already in the Creep object itself to which I already have a reference. I find myself inclined to find a way to run MyCreep methods directly on true Creep objects, where this can refer to the Creep itself. I'd want to use something like in the following pseudocode in-place of new:

    new_MyCreep(creep_id) {
      var creep = Game.getObjectById(creep_id);
      return (MyCreep) creep; // indicates my desire for a "conversion" from Creep to MyCreep
    }
    

    In my naiivete, I see this as avoiding the extra allocation of a dedicated MyCreep object, which now can just operate directly on the API-allocated Creep object. I would then like to use _.forEach(Game.creeps, new_MyCreep) (instead of new MyCreep()) and then use instances of MyCreep to invoke true Creep methods as well as MyCreep methods, like:

    var john = new_MyCreep(Game.creeps['John Galt'].id);
    john.myMoveTo(john.room.controller);
    

    I've seen various people mess with .prototype but I am unable refer to Creep.prototype in the Screeps console and creep_instance.prototype is undefined, so I've given up on pursuing that lead.

    I'm not well-versed enough in JS to know how dumb this all sounds, so please share your thoughts. Is my first method fine, and I'm just overestimating how expensive the extra new calls are? Is the second method even possible? What do you do?

    Cheers!

    Fritz



  • Firstly, I would strongly recommend reading up on how Javascript's prototype system works, as this will probably clear up a lot of things for you. It's a bit confusing at first.

    Very briefly: .prototype is only used to define properties on the constructor, which is actually a function:

    function Foo(name) {
        // this is the constructor
        // Example: assign some arbitrary properties for this instance
        this.name = name;
    }
    Foo.prototype.bar = function () {
        // this is a method
    };
    // Create an instance
    let foo = new Foo('one');
    foo.bar();
    

    When you try to access foo.bar, it will first look up to see if foo has a bar of its own. If not, it will check foo's prototype and see if that has a bar property. If not, it will check the next prototype up the chain and so on.

    Instances don't have .prototype. You can get the prototype of an instance using .__proto__ (unofficial, deprecated) or Object.getPrototypeOf(foo), but you should never normally need to do this.

    Since all objects in Javascript are dynamic, you can add methods to existing classes like this:

    Creep.prototype.foo = function () {
    };
    

    This method will then be available on all instances of Creep, e.g. creep1.foo().

    Generally there are two approaches to structuring code in screeps:

    1. Extend the prototypes
    2. Wrap everything in your own classes

    I would suggest starting with option 1, since even the players using option 2 end up doing this to some extent. This approach is much easier to understand, as you only have to think about the one Creep type, instead of juggling Creep and MyCreep everywhere.

    There are pros and cons to wrapping everything in your own objects. As you've noted, it can be quite a performance hit to construct all these objects every tick. The way around this is to cache the MyCreep instances on the heap and just update the internal .creep references each tick. This is why I suggest leaving this approach until you are more familiar with the game. It's best to deal with game objects (like Creep, Room, etc) and Memory - leave the heap until later.

    Without wishing to confuse you, I'm currently using the prototype approach, but I hate the .prototype syntax so I use the nicer class syntax but then copy all the members onto the existing Creep class like this:

    class MyCreep {
        bar() {
        }
    }
    
    function extendClass(base, extra) {
        let descs = Object.getOwnPropertyDescriptors(extra.prototype);
        delete descs.constructor;
        Object.defineProperties(base.prototype, descs);
    }
    extendClass(Creep, MyCreep);
    

    I never create a MyCreep instance, I just use the builtin Creep objects everywhere, but I can still access all the methods from MyCreep because they've been copied onto Creep.

    I hope some of this is helpful!

    🖖


  • @SystemParadox Yes, that's very helpful, thank you!!

    Extending the prototypes makes sense - I guess I was thrown off because I tried entering Creep.prototype in the Screeps console, and I would see Error: This creep doesn't exist yet. I guess trying to "print" the prototype somehow tries to instantiate a Creep, but playing with it again now I can see that I can absolutely do moveTo = Creep.prototype.moveTo. I like your extendClass function, that makes a lot of sense (after reading the corresponding documentation). I agree that assigning directly to the prototype is ugly. This is pretty much exactly what I was hoping for!

    The way around this is to cache the MyCreep instances on the heap [...]

    Do you mean serializing them to Memory, or is there another "heap" that offers some persistence? I was under the impression from reading the documentation that global state is reset every tick. Is this a change with the introduction of isolated VMs? Is it as simple as using a global object/array?

    var cached_creeps = {};
    
    module.exports.loop = {
      if (!cached_creeps) { cached_creeps = [new MyCreep(Game.creeps.john_galt))]; }
      cached_creeps[0].say("I am " + cached_creeps[0].name);
    }
    


  • Extending the prototypes makes sense - I guess I was thrown off because I tried entering Creep.prototype in the Screeps console, and I would see Error: This creep doesn't exist yet. I guess trying to "print" the prototype somehow tries to instantiate a Creep

    This really threw me for a bit! You seem to have found the answer although you may not realise it so it's probably worth explaining exactly what's going on here!

    Accessing Creep.prototype doesn't create anything. What's actually happening here is that the console calls .toString() on the object (e.g. Creep.prototype.toString()). When you call creep1.toString() it walks up the prototype chain and ends up calling Creep.prototype.toString(), but the this context is still creep1. If you call Creep.prototype.toString() directly, the this context is Creep.prototype. The toString() function tries to get this.name, but it's a getter, and it throws an error if the object doesn't have this.id.

    This should work:

    > !! Creep.prototype
    true
    

    Do you mean serializing them to Memory, or is there another "heap" that offers some persistence? I was under the impression from reading the documentation that global state is reset every tick. Is this a change with the introduction of isolated VMs? Is it as simple as using a global object/array?

    var cached_creeps = {};
    
    module.exports.loop = {
      if (!cached_creeps) { cached_creeps = [new MyCreep(Game.creeps.john_galt))]; }
      cached_creeps[0].say("I am " + cached_creeps[0].name);
    }
    

    Yes that's exactly it. There are basically 3 kinds of memory in screeps:

    1. Game objects (e.g. Creep, Room, etc). These are only valid for one tick
    2. The heap - that is, global variables or module scoped variables as in your example. These will last until a "global reset", which happens somewhat randomly depending on server load, but is generally a few hundred ticks or so. A global reset will also occur when you update your code.
    3. Memory (including creep.memory, room.memory, etc). This is actually a bit of a misnomer. It's a flat JSON structure, so it's more like storing it on disk. Only cleared if you respawn.

    I find it quite useful to use game objects to cache values that are only valid in the current tick. For example, I do things like this a lot:

    Room.prototype.getRepairTargets = function () {
        if (! this._repairTargets) {
            this._repairTargets = this.find(FIND_MY_STRUCTURES, {
                filter: struct => struct.hits < struct.hitsMax,
            })
        }
        return this._repairTargets;
    };
    

    That way I can call this.room.getRepairTargets() on several different creeps but it will only call find once each tick.



  • I do not use it, but have seen or heard of multiple instances of game object wrappers that ppl have used (e.g. your new MyCreep(creepID)) and can provide some insight for ya.

    I've heard of ppl using Proxy objects to create a "persistent object" similar to what the devs said they intended to implement eventually (TM) about a year or so ago. Basically, internal to the proxy object is the game object's ID, and each new tick, it grabs that game object again, and proxies any function calls to the wrapped object. you could add additional functionality by adding new handlers to he proxy itself, and let the proxy route the actual intents to the game object.

    Also, there's the "initialize once, use multiple ticks" concept, which is how my Kernel, Scheduler and libraries work in my OS codebase. Basically, you initialize the object once, and assign it to global (global.kernel = new Kernel(Memory.SempOS)) and it will persist as long as your global does. It will re-init every time you push your code, or get a global reset for some reason. With IVM, you only have a single global, and resets are considerably more rare, so it's feasible to do things like this whereas 2+ years ago, it would have been much more difficult. Tying this concept to game objects, you'd init the object with an object's ID, and it would cache the object for that tick. it'd looks something like this:

    function MyCreep(id) {
        this.id = id;
        this.creep = Game.getObjectById(this.id);
        this.tick = Game.time;
    }
    
    MyCreep.prototype.move = function(dir) {
        this.refreshCreep();
        return this.creep.move(dir);
    }
    
    MyCreep.prototype.refreshCreep = function() {
        if(this.tick != Game.time) {
            this.creep = Game.getObjectById(this.id);
            this.tick = Game.time;
        }
    }
    

    The third and final option is what you mentioned previously, which is initializing and disposing of new objects each creep so you dont need the refreshCreep() in the example above.

    Personally, I lean heavily on modifying the existing objects' prototypes, either through Object.createProperty() or just appending more functions onto it. That way, i dont get the potentially expensive new calls, and it just works.

    Hope this helps, @fritzy...



  • @SystemParadox

    What's actually happening here is that the console calls .toString() on the object (e.g. Creep.prototype.toString()). [...] The toString() function tries to get this.name, but it's a getter, and it throws an error if the object doesn't have this.id.

    Nice, yeah, I figured it would be part of the toString invocation. Good find!

    The heap - that is, global variables or module scoped variables as in your example. These will last until a "global reset" [...]

    OK, makes sense. I suppose not all of the docs have been updated since the introduction of iVMs. Thanks, that's useful!

    I do things like this a lot:

    Room.prototype.getRepairTargets = function () {
        if (! this._repairTargets) {
            this._repairTargets = this.find(FIND_MY_STRUCTURES, {
                filter: struct => struct.hits < struct.hitsMax,
            })
        }
        return this._repairTargets;
    };
    

    That way I can call this.room.getRepairTargets() on several different creeps but it will only call find once each tick.

    Right. I found that JS supports the same "decorator" syntax as Python (a language with which I am much more familiar!) and I figured out how to write a decorator to seamlessly memoize the results of a property descriptor's getter, with the goal of simplifying this common pattern. I use it like:

    class MyRoom {
      @memoize
      get repairTargets() {
        return this.find(FIND_MY_STRUCTURES, {
          filter: struct => struct.hits < struct.hitsMax
        });
      }
    }
    

    @SemperRabbit That tracks with what I'm learning - I might make a Proxy of my own. Thanks for your input! 🙂