I decided to learn Javascript by building several retro games with a simple twist -- hexagon-ifying everything. For the framework, I chose Vue (even though Unity or Phaser are probably much better suited for the job) since Vue has a soft learning curve and I hope to use it for future non-gaming projects.

All told, the implementation was easy. I wrote hexagon Minesweeper in a handful of days and hexagon Snake in a few more.

Hexagon Snake.
Hexagon Snake.

Snake's core is a single SnakeGrid component that calculates game state and passes on the relevant information to its children BaseHexagon components. Each BaseHexagon knows where it's located on the grid as well as how to redraw itself whenever information about its color changes. Like I said, easy.

I didn't have problems manually playing the games on Chrome desktop, and I tested Firefox desktop as well, but I don't have easy access to mobile, Safari, or Internet Explorer, so I don't yet know how well my games work there. Coming from a background in backend engineering, I have a lot to learn about browser compatibility. A conversation for another day.

Thinking about browser compatibility made me curious about other metrics, specifically performance and frame rendering. For gaming these days, 30fps is good enough, but 60fps seems to be the golden standard. I installed the Vue.js devtools extension on Firefox and recorded the performance of Snake. For the beginner level, it hovered around 54-58fps:

Beginner-level Snake performance pre-optimization is ~56fps.
Beginner-level Snake performance pre-optimization is ~56fps.

Weee! Good enough for me!

But then I measured the expert level, and it was a different story:

Expert-level Snake performance pre-optimization is ~36fps.
Expert-level Snake performance pre-optimization is ~36fps.

An average of 36fps? And a low point of 32fps?! Okay, so that isn't that bad since we're talking about a Snake game where the movement should look choppy anyway to preserve the retro feel.

But then again, we're talking about a Snake game with only two tiny actors -- a snake and an apple -- and not a whole lot else going on. Video games with significantly more resolution and action are able to perform comparably or better. And even though the expert-level snake moves at 5x the speed of the beginner-level one, it seems ridiculous that it would come with a drop of 20fps. So what's going on?

In addition to the frames-per-second graph, Vue.js devtools provides a performance breakdown by rendered component. In my case, the individual BaseHexagon components rendered quickly and infrequently, but the SnakeGrid component had a very expensive average render time of 50ms, or 1000ms/50ms = 20fps.

Average time to render SnakeGrid component pre-optimization is 50ms.
Average time to render SnakeGrid component pre-optimization is 50ms.

This is where the performance difference between the beginner-level and expert-level rounds started making sense. My choppy snake was specifically set to move 2 hexagon units/second at the beginner level, so the expensive SnakeGrid rendering only happened 2 times of those 56 frames rendered each second. Same thing for the expert level, but at a speed of 10 hexagon units/second, only 10 of the 36 frames rendered each second were the expensive 50ms ones.

I needed more information. Where was the bottleneck within SnakeGrid? My first suspects were the draw functions on the HTML5 canvas since I was drawing each hexagon individually, but with all those calls encapsulated within my BaseHexagon component, I knew from Vue.js devtools that they only took ~1ms total to render. Definitely not the culprit.

So I turned to the performance tab of my trusty Chrome devtools and started recording there. Whenever the setInterval timer fired to move the snake, I'd see a flame graph for a task that took ~47ms, matching the 50ms render time reported by Vue.js devtools.

Moving the snake pre-optimization is 47ms.
Moving the snake pre-optimization is 47ms.

On the very right side of the above graph, you can see a sliver where all the draw functions are called (in the cyan and lilac colors). The thin width is not a surprise; Vue.js devtools already informed us that the BaseHexagons all render within 1ms.

The vast majority of the flame graph seemed to be internal Vue functions. Zooming in, I saw three prominent functions:

  • updateComponent(): this is Vue reacting to a change in the game state within the SnakeGrid component
  • updateChildren(): this is Vue propagating changes from SnakeGrid to its internal array of BaseHexagons
  • updateChildComponent(): this is Vue updating an individual BaseHexagon with its new set of props
The validateProp() function can take >1.5ms to complete pre-optimization.
The validateProp() function can take >1.5ms to complete pre-optimization.

This is Vue reactivity in action! But the validateProp() function inside updateChildComponent() sometimes seemed to stall, taking 6-8x more time than the typical ~0.3ms. Since each BaseHexagon had 20 props and each snake movement resulted in at least 3 BaseHexagons changing, it was easy to understand how the updateChildren() function could end up taking 30ms or more.

I next combed through the Vue source code to check out its validateProp() implementation. Nothing crazy seemed to be happening, and all but 2 of my BaseHexagon props were of String or Number type, so I wasn't sure what was causing the slowdown. The other 2 props were of type HTMLCanvasElement and Object (with only 2 keys: euclidean row and column), so I didn't think they were causing the issue. I couldn't figure out how to investigate the arguments passed to the functions on the flame graph, so I was at a standstill.

Finally, I decided that if I couldn't reduce the time that elapsed per validateProp() call, then I could at least reduce the number of times that validateProp() needed to be called. I wrapped all the BaseHexagon props into a single prop of type Object and destructured it via Vue computed properties instead. Then, I reran my performance tests, and voilà:

Moving the snake post-optimization is 24ms.
Moving the snake post-optimization is 24ms.
Average time to render SnakeGrid component post-optimization is 37ms.
Average time to render SnakeGrid component post-optimization is 37ms.
Expert-level Snake performance post-optimization is ~46ms.
Expert-level Snake performance post-optimization is ~46ms.

That's right -- for the expert level, the fps jumped from ~36fps to ~46fps! Another mysterious case closed!

Okay, well sure. I guess I technically discovered a source of inefficiency and improved it, but I'm not sure that it was the right solution. Having explicit component props that can be validated creates a clean interface between components, and removing that just to get a speedup I didn't even need seems silly. It feels like I've done something incorrect at an architectural level instead. Maybe a better question to ask is: why did my BaseHexagon need 20 props in the first place? I have props for color and border width and sprite name and grid location, but do all those things really need to be fed in through the SnakeGrid parent, or is there a better way to pass that information to a BaseHexagon? Is there a more appropriate way to structure my components, or is this a latency I need to accept by choosing to use Vue to build a "fast-paced" game composed of hundreds of instantiated components?

I don't know the answers right now, but I have a lot to think about at least.