Description
Super simple demo project: Recursive diamond animation!
Up to five "stack layers" deep, Scratch considers the possibility of a recursive procedure call, — that is, a custom block that's been behaviorally nested inside of its own definition, directly or indirectly! — and if so, it performs a yield, which may for example wait for a screen refresh.
The sneaky thing is in the definition of "stack layer". In Scratch, a stack layer is any level of evaluation nesting. It's sort of like the JavaScript call stack, but because everything in Scratch is a block, even basic control structures push a new stack layer.
Imagine something like this:
*myCoolScript() {
this.callStack.push({why: 'myCoolScript'});
if ('foo' !== 'bar') {
this.callStack.push({why: 'if'});
yield* this.sayAndWait('That makes sense!', 2);
yield* this.doSomethingElse();
this.callStack.pop();
} else {
this.callStack.push({why: 'if-else'});
yield* this.thinkAndWait('Oh dear, oh dear', 3);
while (this.fruits.length > 0) {
this.callStack.push({why: 'while'});
yield* this.eatFruit(this.fruits[Math.floor(Math.random() * this.fruits.length)]);
this.callStack.pop();
}
this.callStack.pop();
}
this.callStack.pop();
}
*doSomethingElse() {
this.callStack.push({why: 'doSomethingElse'});
yield* this.myCoolScript();
this.callStack.pop();
}
*eatFruit(fruit) {
this.callStack.push({why: 'eatFruit'});
if (Math.random() > 0.25) {
this.callStack.push({why: 'if'});
if (Math.random() > 0.25 /* no really */) {
this.callStack.push({why: 'if'});
yield* this.myCoolScript();
this.callStack.pop();
}
this.callStack.pop();
}
this.callStack.pop();
}
Now this is terrible to read, but don't let that scare you away just yet! It's just to explain what Scratch is seeing. We're not Scratch, so we can make a slightly nicer abstraction out of this, but first we have to understand what Scratch is doing.
Let's imagine we follow the path into this.doSomethingElse()
because 'foo'
does in fact !== 'bar'
. The actual execution order, which the JavaScript or Scratch runtime would take, looks like this:
this.callStack.push({why: 'myCoolScript'});
» Stack: [myCoolScript]this.callStack.push({why: 'if'});
» Stack: [myCoolScript, if]yield* this.sayAndWait('That makes sense!', 2);
» outputyield* this.doSomethingElse();
» enter custom block!this.callStack.push({why: 'doSomethingElse'});
» Stack: [myCoolScript, if, doSomethingElse]yield* this.myCoolScript();
» enter custom block!this.callStack.push({why: 'myCoolScript'});
» Stack: [myCoolScript, if, doSomethingElse, myCoolScript]- ...and on and on, infinitely, since this will just recurse forever.
So, right before Scratch begins executing the contents of any custom block (steps 4 and 6 above), it checks out the top five layers of the stack list. If any of those layers the custom block which it's in the process of starting, Scratch inserts a yield (possibly waiting for a screen refresh, etc).
At step 4, it's entering doSomethingElse
. The stack is [myCoolScript, if]
. The top five layers of that stack are also just [myCoolScript, if]
. That doesn't include doSomethingElse
, so Scratch doesn't insert a yield here. (It just begins executing the script's contents immediately.)
At step 6, it's entering myCoolScript
. The stack is [myCoolScript, if, doSomethingElse]
. The top five layers of that stack are also just [myCoolScript, if, doSomethingElse]
, and of course, that includes myCoolScript
... so it inserts a yield, here.
OK, let's say the sky is falling!! Now 'foo' === 'bar'
! What happens this time!?
this.callStack.push({why: 'myCoolScript'});
» Stack: [myCoolScript]this.callStack.push({why: 'if-else'});
» Stack: [myCoolScript, if-else]yield* this.thinkAndWait('Oh dear, oh dear', 3);
» outputthis.callStack.push({why: 'while'});
» Stack: [myCoolScript, if-else, while] (assuming we have any fruit, of course ✨)yield* this.eatFruit(this.fruits[...]);
» enter custom block!this.callStack.push({why: 'eatFruit'});
» Stack: [myCoolScript, if-else, while, eatFruit]this.callStack.push({why: 'if'});
» Stack: [myCoolScript, if-else, while, eatFruit, if] (assuming we're lucky)this.callStack.push({why: 'if'});
» Stack: [myCoolScript, if-else, while, eatFruit, if, if] (assuming we're lucky again)yield* this.myCoolScript();
» enter custom block!this.callStack.push({why: 'myCoolScript'});
» Stack: [myCoolScript, if-else, while, eatFruit, if, if, myCoolScript]- ...and on and on, infinitely, since this will just recurse forever. Or until we're out of fruit!
The same recursion rule applies, of course, during steps 5 and 9 here.
Step 5 is just like before - no eatFruit
on the stack, so no yield.
But step 9 is more interesting. The stack is [myCoolScript, if-else, while, eatFruit, if, if]
. The top five layers of this, though, are only [if-else, while, eatFruit, if, if]
! Even though we're recursing into myCoolScript
, which is on the stack, it's too far away from the top, and Scratch never sees it. So... it doesn't insert a yield here!
See how that only happens because of how far we were nested inside ifs and if-elses and other custom blocks? Those simple control strucures really made a difference—one that could have a real impact, depending how badly a project turns out to depend on Scratch's behavior.
OK, we'll reply to this with a couple syntactical options we've considered, but here's just the summary of Scratch's behavior, to start!