Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to define cycles with observables

I'm trying to set up the update loop of a simple game, built with observables in mind. The top-level components are a model, which takes input commands, and produces updates; and a view, which displays the received updates, and produces input. In isolation, both work fine, the problematic part is putting the two together, since both depend on the other.

With the components being simplified to the following:

var view = function (updates) {
  return Rx.Observable.fromArray([1,2,3]);
};
var model = function (inputs) {
  return inputs.map(function (i) { return i * 10; });
};

The way I've hooked things together is this:

var inputBuffer = new Rx.Subject();
var updates = model(inputBuffer);
var inputs = view(updates);
updates.subscribe(
    function (i) { console.log(i); },
    function (e) { console.log("Error: " + e); },
    function () { console.log("Completed"); }
);
inputs.subscribe(inputBuffer);

That is, I add a subject as a placeholder for the input stream, and attach the model to that. Then, after the view is constructed, I pass on the actual inputs to the placeholder subject, thus closing the loop.

I can't help but feel this is not the proper way to do things, however. Using a subject for this seems to be overkill. Is there a way to do the same thing with publish() or defer() or something along those lines?

UPDATE: Here's a less abstract example to illustrate what I'm having problems with. Below you see the code for a simple "game", where the player needs to click on a target to hit it. The target can either appear on the left or on the right, and whenever it is hit, it switches to the other side. Seems simple enough, but I still have the feeling I'm missing something...

//-- Helper methods and whatnot
// Variables to easily represent the two states of the target
var left = 'left';
var right = 'right';
// Transition from one side to the other
var flip = function (side) {
  if (side === left) {
    return right;
  } else {
    return left;
  }
};
// Creates a predicate used for hit testing in the view
var nearby = function (target, radius) {
  return function (position) {
    var min = target - radius;
    var max = target + radius;
    return position >= min && position <= max;
  };
};
// Same as Observable.prototype.scan, but it also yields the initial value immediately.
var initScan = function (values, init, updater) {
  var initValue = Rx.Observable.return(init);
  var restValues = values.scan(init, updater);
  return initValue.concat(restValues);
};

//-- Part 1: From input to state --
var process = function (inputs) {
  // Determine new state based on current state and input
  var update = function(current, input) {
    // Input value ignored here because there's only one possible state transition
    return flip(current);
  };
  return initScan(inputs, left, update);
};
//-- Part 2: From display to inputs --
var display = function (states) {
  // Simulate clicks from the user at various positions (only one dimension, for simplicity)
  var clicks = Rx.Observable.interval(800)
      .map(function (v) {return (v * 5) % 30; })
      .do(function (v) { console.log("Shooting at: " + v)})
      .publish();
  clicks.connect();

  // Display position of target depending on the model
  var targetPos = states.map(function (state) {
    return state === left ? 5 : 25;
  });
  // Determine which clicks are hits based on displayed position
  return targetPos.flatMapLatest(function (target) {
    return clicks
        .filter(nearby(target, 10))
        .map(function (pos) { return "HIT! (@ "+ pos +")"; })
        .do(console.log);
  });
};

//-- Part 3: Putting the loop together 
/**
 * Creates the following feedback loop:
 * - Commands are passed to the process function to generate updates.
 * - Updates are passed to the display function to generates further commands.
 * - (this closes the loop)
 */
var feedback = function (process, display) {
  var inputBuffer = new Rx.Subject(),
      updates = process(inputBuffer),
      inputs = display(updates);
  inputs.subscribe(inputBuffer);
};
feedback(process, display);
like image 403
pkt Avatar asked Oct 01 '22 15:10

pkt


1 Answers

I think I understand what you are trying to achieve here:

  • How can I get a sequence of input events going in one direction that feed into a model
  • But have a sequence of output events going in the other direction that feed from the model to the view

I believe the answer here is that you probably want to flip your design. Assuming an MVVM style design, instead of having the Model know about the input sequence, it becomes agnostic. This means that you now have a model that has a InputRecieved/OnInput/ExecuteCommand method that the View will call with the input values. This should now be a lot easier for you to deal with a "Commands in one direction" and "Events in the other direction" pattern. A sort of tip-of-the-hat to CQRS here.

We use that style extensively on Views+Models in WPF/Silverlight/JS for the last 4 years.

Maybe something like this;

var model = function()
{
    var self = this;
    self.output = //Create observable sequence here

    self.filter = function(input) {
        //peform some command with input here
    };
}

var viewModel = function (model) {
    var self = this;
    self.filterText = ko.observable('');
    self.items = ko.observableArray();
    self.filterText.subscribe(function(newFilterText) {
        model.filter(newFilterText);
    });
    model.output.subscribe(item=>items.push(item));
};

update

Thanks for posting a full sample. It looks good. I like your new initScan operator, seems an obvious omission from Rx.

I took your code an restructured it the way I probably would have written it. I hope it help. The main things I did was encapsulted the logic into the model (flip, nearby etc) and have the view take the model as a parameter. Then I did also have to add some members to the model instead of it just being an observable sequence. This did however allow me to remove some extra logic from the view and put it in the model too (Hit logic)

//-- Helper methods and whatnot

// Same as Observable.prototype.scan, but it also yields the initial value immediately.
var initScan = function (values, init, updater) {
  var initValue = Rx.Observable.return(init);
  var restValues = values.scan(init, updater);
  return initValue.concat(restValues);
};

//-- Part 1: From input to state --
var process = function () {
  var self = this;
  var shots = new Rx.Subject();
  // Variables to easily represent the two states of the target
  var left = 'left';
  var right = 'right';
  // Transition from one side to the other
  var flip = function (side) {
    if (side === left) {
      return right;
    } else {
      return left;
    }
  };
  // Determine new state based on current state and input
  var update = function(current, input) {
    // Input value ignored here because there's only one possible state transition
    return flip(current);
  };
  // Creates a predicate used for hit testing in the view
  var isNearby = function (target, radius) {
    return function (position) {
      var min = target - radius;
      var max = target + radius;
      return position >= min && position <= max;
    };
  };

  self.shoot = function(input) { 
    shots.onNext(input); 
  };

  self.positions = initScan(shots, left, update).map(function (state) {
    return state === left ? 5 : 25;
  });

  self.hits = self.positions.flatMapLatest(function (target) {  
    return shots.filter(isNearby(target, 10));
  });
};
//-- Part 2: From display to inputs --
var display = function (model) {
  // Simulate clicks from the user at various positions (only one dimension, for simplicity)
  var clicks = Rx.Observable.interval(800)
      .map(function (v) {return (v * 5) % 30; })
      .do(function (v) { console.log("Shooting at: " + v)})
      .publish();
  clicks.connect();

  model.hits.subscribe(function(pos)=>{console.log("HIT! (@ "+ pos +")");});

  // Determine which clicks are hits based on displayed position
  model.positions(function (target) {
    return clicks
        .subscribe(pos=>{
          console.log("Shooting at " + pos + ")");
          model.shoot(pos)
        });
  });
};

//-- Part 3: Putting the loop together 
/**
 * Creates the following feedback loop:
 * - Commands are passed to the process function to generate updates.
 * - Updates are passed to the display function to generates further commands.
 * - (this closes the loop)
 */
var feedback = function (process, display) {
  var model = process();
  var view = display(model);
};
feedback(process, display);
like image 153
Lee Campbell Avatar answered Oct 12 '22 11:10

Lee Campbell