This Notebook only works live with a running IPython kernel!

You also need my nb_assets library for the coffeescript cell magic.

Setting up the presentation....


In [ ]:
import nb_assets
nb_assets.load_magics()

In [ ]:
import numpy as np
import tornado

In [ ]:
from ipywidgets import widgets
from traitlets import Unicode, Bool, Dict, List

In [ ]:
from bokeh.io import output_notebook
output_notebook()

In [ ]:
%%html
<style>
 .container.slides .celltoolbar, .container.slides .hide-in-slideshow, #exit_b, #help_b {
    display: None ! important;
}
section#slide-0-0 {
}
// Some styles to make presentation better for video capture
.container.slides {
    width: 1300px;
    
}
.container.slides .text_cell_render{
    font-size: 2em;
}
.container.slides .cell .input {
    font-size: 1.3em;
}
</style>

In [ ]:
from IPython import display
from IPython.core.magic import register_cell_magic


@register_cell_magic
def html_nocode(line, cell):
    hide_code_in_slideshow()
    display.display_html(cell, raw=True)
    
def hide_code_in_slideshow():    
    import binascii
    import os
    uid = binascii.hexlify(os.urandom(8)).decode()    
    html = """<div id="%s"></div>
    <script type="text/javascript">
        $(function(){
            var p = $("#%s");
            if (p.length==0) return;
            while (!p.hasClass("cell")) {
                p=p.parent();
                if (p.prop("tagName") =="body") return;
            }
            var cell = p;
            cell.find(".input").addClass("hide-in-slideshow")
        });
    </script>""" % (uid, uid)
    display.display_html(html, raw=True)

Jupyter-Snake

A Snake game in Jupyter Notebooks using widgets and Bokeh plots

The browser-side widget

The browser-side widget is relatively simple. The corresponding Python declaration of the widget (see further down) defines a few traits, which are automatically synced by the widget system. So when the "render" method is called, the "scripts" and "divs" traits are already filled with the initial Bokeh data.

The "divs" dictionary contains only one entry, "plot", which is inserted into the widget's DOM element. The "scripts" trait contains a string with a javascript tag. This is appended to the document, and is executed automatically by the browser some time after "render" is finished. That is why some other setup work needs to be done later inside a timeout.

The browser-side and python-side widget instances can communicate with each other either by changing the traits (which are then synced to the other side) or by custom messages. The browser-side widget in this case responds to only one such message, to replace the contents of a bokeh data source.

On the other hand, it sends keypress events to the Python-side.


In [ ]:
%%coffeescript
requirejs.undef('snake')
define('snake', ["jquery", "widgets/js/widget"], ($, widget)->
  console.log("loading widget")
  SnakeWidget = widget.DOMWidgetView.extend({
    render: ->
      SnakeWidget.__super__.render.apply(this, arguments)
      html = @model.get("divs")["plot"]
      html = "<div tabindex='1'>"+html+"</div>"
      @setElement($(html))
      setEvents= ()=>
            $(@el).keydown($.proxy(@onKeypress, @))
            
      setJs= ()=>
         js = @model.get("scripts")
         $(js).appendTo(document.body)
         setEvents(setEvents, 1)
      setTimeout(setJs, 1)
      
      @model.on('msg:custom', (msg)=> @handle_custom_message(msg))
      
    onKeypress: (event)->
            # only capture the relevant keys
            if event.which in @model.get('capture_codes')
                event.preventDefault()
                event.stopPropagation()
                @send({'msg_type':'key', 'which': event.which})
    handle_custom_message: (data)->
        switch
            when (data.custom_type == "replace_bokeh_data_source")
                ds = Bokeh.Collections(data.ds_model).get(data.ds_id)
                ds.set($.parseJSON(data.ds_json))
                ds.trigger("change")
  })
  
  return {
    SnakeWidget: SnakeWidget
  }
)

The Python-side Widget

The Widget class creates the Bokeh models for the game world. These are then serialized into their scripts and components parts and implicitly send over to the Browser. The data sources are kept as class members to change them later.

A "game loop" is achieved by using Tornado callbacks. The IPython Kernel running this codes uses PyZMQ to communicate with the Jupyter Notebook Server, which in turn communicates with the Browser. However, PyZMQ uses a Tornado event loop internally, which is already running by the time the widget starts up.

Bokeh's ColumnDataSource model contains columns of lists. Bokeh's "glyphs" (think of a glyph as a shape) use the rows of this table to draw shapes on the plot. So every row of the data source is another shape. You can specify which columns are used for what glyph parameter.

To update a data source the Python widget instance needs to send a custom message to its corresponding Javascript instance. Every Bokeh data source has its own Id, which is consistent between Python and Javascript, and that is why the updating is so easy.

The "Gameover" Text is another special case, where only the "text_alpha" parameter is dynamically set, and only one "row" means only one instance of the text.


In [ ]:
# Key code constants
LEFT_ARROW = 37
UP_ARROW = 38
RIGHT_ARROW = 39
DOWN_ARROW = 40


from bokeh.models import Plot, Rect, Circle, Text, Range1d, ColumnDataSource
from bokeh.embed import components
from bokeh.protocol import serialize_json

class SnakeWidget(widgets.DOMWidget):
    """ Python-side class for the SnakeWidget. """
    _view_module = Unicode('snake', sync=True)
    _view_name = Unicode('SnakeWidget', sync=True)
    scripts = Unicode(sync=True)
    divs = Dict(sync=True)
    capture_codes = List(sync=True)
    running = Bool(False, sync=True)

    def __init__(self, *args, **kwargs):
        widgets.DOMWidget.__init__(self,*args, **kwargs)        
        self.on_msg(self._handle_custom_msg)
        
        self.grid_size = 60
        
        self.pos_x = 0
        self.pos_y = self.grid_size / 2
        self.direction = RIGHT_ARROW
        self.tail = np.repeat(np.array([(self.pos_x, self.pos_y)]), 8,axis=0)
        self.food = np.random.randint(1,50, (10,2))

        dim = self.grid_size * 10
        
        self.plot = Plot(
            x_range=Range1d(0, self.grid_size),
            y_range=Range1d(0, self.grid_size),
            plot_width=dim, plot_height=dim,
            background_fill='black',
            border_fill='white',
            outline_line_color='white',
        )
        self.tail_ds = ColumnDataSource({'x': self.tail[:,0], 'y': self.tail[:,1]})
        self.plot.add_glyph(
            self.tail_ds, 
            Rect(x="x", y="y", width=1, height=1, dilate=True)
        )
        self.food_ds = ColumnDataSource({'x': self.food[:,0], 'y': self.food[:,1]})
        self.plot.add_glyph(
            self.food_ds, 
            Circle(x="x", y="y", radius=0.45, fill_color="LemonChiffon")
        )
        c = self.grid_size / 2
        self.gameover_ds = ColumnDataSource({'alpha':[0], 'x': [c], 'y': [c], 'text': ["Game Over"]})
        self.plot.add_glyph(
            self.gameover_ds, 
            Text(text='text', x='x', y='y', text_alpha='alpha', text_font_size="%spt" % c, text_align="center", text_color='pink')
        )
        scripts, divs = components({'plot': self.plot})
        self.scripts = scripts
        self.divs = divs
        self.capture_codes = [LEFT_ARROW, UP_ARROW, RIGHT_ARROW, DOWN_ARROW]
        
    def replace_bokeh_data_source(self, ds):
        self.send({"custom_type": "replace_bokeh_data_source",
                "ds_id": ds.ref['id'],
                "ds_model": ds.ref['type'],
                "ds_json": serialize_json(ds.vm_serialize())
        })
    
    def _handle_custom_msg(self, content, extra=None):
        if content['msg_type']=='stop':
            self.stopped = True
        if content['msg_type'] == 'key':
            w = content['which']
            self.handle_key(w)
            
    def handle_key(self, w):
        if w in [LEFT_ARROW, UP_ARROW, RIGHT_ARROW, DOWN_ARROW]:
            self.direction = w
            
    def iterate(self):
        if self.direction == RIGHT_ARROW:
            self.pos_x += 1
        elif self.direction == LEFT_ARROW:
            self.pos_x -= 1
        elif self.direction == UP_ARROW:
            self.pos_y += 1
        elif self.direction == DOWN_ARROW:
            self.pos_y -= 1
        position = np.array((self.pos_x,self.pos_y))
        if (self.tail == position).all(axis=1).any() \
                or (position==0).any() \
                or (position==self.grid_size).any():
            self.running = False
            self.gameover_ds.data['alpha'] = [1]
            self.replace_bokeh_data_source(self.gameover_ds)
            return
        self.tail = np.concatenate((self.tail,(position,)), axis=0)
        if (self.food == position).all(axis=1).any():
            self.food = np.array([r for r in self.food if (r!=position).any()])
            self.food = np.concatenate((self.food, np.random.randint(1,self.grid_size,
                                                            (1,2))), axis=0)
            self.food_ds.data['x'] = self.food[:,0]
            self.food_ds.data['y'] = self.food[:,1]
            self.replace_bokeh_data_source(self.food_ds)
        else:
            self.tail = self.tail[1:,:]
        self.tail_ds.data['x'] = self.tail[:,0]
        self.tail_ds.data['y'] = self.tail[:,1]
        self.replace_bokeh_data_source(self.tail_ds)
        
    def timer(self):
        if not self.running:
            return
        loop = tornado.ioloop.IOLoop.current()
        self.iterate()
        loop.call_later(0.1, self.timer)
        
    def run(self):
        self.running = True
        self.timer()
        
    def stop(self):
        self.running = False

In [ ]:
w = SnakeWidget()
display.display(w)
w.run()

In [ ]:
w.stop()

The End

Andreas Klostermann

Twitter: @bayesianhorse

Github:

* Bokeh Snake: https://github.com/akloster/bokeh-snake
* NB Assets: https://github.com/akloster/nb-assets

In [ ]: