Skip to content

Instantly share code, notes, and snippets.

@AdrianRossouw
Last active August 29, 2015 14:05
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save AdrianRossouw/896c9f1588e6b0051ab3 to your computer and use it in GitHub Desktop.
Save AdrianRossouw/896c9f1588e6b0051ab3 to your computer and use it in GitHub Desktop.
werewolves.io world timer model

This is the timer model I built for the werewolves.io project a few months ago.

The entire project was built on the back of state.js and backbone.

What was interesting about this timer model, and most of the werewolves project actually, is that it formed part of
a distributed state machine. The timer state changes were streamed over websockets to each of the clients, and when
the server manipulated the timer, it would propagate to all connected browser tabs.

I also needed to be able to serialize the state to be able to set the initial data for each new load, and I also
had to make some considerations towards eventually storing the active game in something like redis, to survive server restarts and/or clustering.

Since it was so critical to the operation of the project, I also wrote a fair amount of unit tests to make sure the state transitions work correctly.

App = require('../app')
state = require('state')
_ = require('underscore')
Backbone = require('backbone')
debug = require('debug')('werewolves:model:timer')
Models = App.module "Models"
# World timer object.
#
# the timer is a singleton object attached to the
# world, that allows us to centralize and sync the
# game's timed events.
#
# By having it a separate model, we also allow the
# interface to represent it as a countdown, giving
# valuable feedback on how long the round will go on.
class Models.Timer extends Models.BaseModel
url: 'timer'
@attribute 'limit'
# this is sort of private.
# it should never be set directly, as
# the timer model will overwrite it the
# whole time.
@attribute '_endTime'
@attribute '_remaining'
initialize: (data = {}) ->
@limit = data.limit or 0
@_endTime ?= data._endTime
@_remaining ?= data._remaining
super
@state().change(data._state or 'stopped')
@trigger('state', @state().path())
@publish()
toJSON: (session) ->
json = super
json._state = @state().path()
json
destroy: ->
super
@stopListening()
@off
# return the ms remaining on the counter.
remaining: -> @limit
# return the time when the counter will (would?) end.
deadline: -> Date.now() + @remaining()
## section: methods
# start the countdown.
start: ->
@go('active')
# pause the coundown where it is
pause: -> @go('paused')
# resume the countdown (alias for start)
resume: -> @start()
# stop the countdown and reset to 0.
stop: -> @go('stopped')
# reset the countdown
reset: ->
before = @state().name
@stop()
@start() if before is 'active'
# end the timer, triggering the 'end' event
end: =>
@stop()
@trigger 'end'
# while running, trigger a tick event every second
#
# allows us to tie the interface to the progression
# of time.
tick: =>
@trigger 'tick', @remaining()
## section: state machine
initState: ->
state @,
# timer is frozen
inactive: state 'abstract default',
# remaining time reset.
# only allow leaving this state if there is a limit.
stopped: state 'default',
arrive: ->
@unset('_endTime') if @_endTime
@unset('_remaining') if @_remaining
release:
active: -> !!@owner.limit
exit: ->
@_remaining = @limit
# remaining time kept.
paused:
remaining: -> @_remaining
# timer is running
active:
# calculate the deadline based on
# time remaining.
arrive: ->
@_interval = setInterval @tick, 100
@_timeout = setTimeout @end, @_remaining
@_endTime ?= Date.now() + @_remaining
deadline: -> @_endTime
remaining: -> @_endTime - Date.now()
# this countdown has run out,
# and needs to be restarted.
exit: ->
@_remaining = @_endTime - Date.now()
@unset('_endTime')
clearTimeout @_timeout
delete @_timeout
clearInterval @_interval
delete @_interval
# This test is built to confirm that the session
# state machine behaves as expected.
App = require('../app')
Models = require('../models')
should = require('should')
sinon = require('sinon')
_ = require('underscore')
fiveSecs = 5000
tenSecs = 10000
twentySecs = 20000
thirtySecs = 30000
oneMin = 60000
$json = {}
describe 'timer model', ->
before ->
App.server = true
@clock = sinon.useFakeTimers()
@timer = new Models.Timer()
it 'should start in a stopped state', ->
@timer.state().path().should.equal 'inactive.stopped'
it 'should have a time limit of 0', ->
@timer.limit.should.equal 0
it 'should not have an _endTime value', ->
should.not.exist @timer._endTime
describe 'while stopped', ->
it 'should return now() as the deadline', ->
@timer.deadline().should.equal Date.now()
it 'should always return 0 ms remaining', ->
@timer.remaining().should.equal 0
it 'should not be able to start without a limit', ->
@timer.start()
@timer.state().path().should.equal 'inactive.stopped'
it 'should allow a time limit to be set (in miliseconds)', ->
@timer.limit = thirtySecs
@timer.limit.should.equal thirtySecs
it 'should always give me the deadline as $limit ms in the future', ->
@timer.deadline().should.equal Date.now() + @timer.limit
@clock.tick oneMin
@timer.deadline().should.equal Date.now() + @timer.limit
it 'should always give me the remaining ms as the $limit', ->
@timer.remaining().should.equal @timer.limit
@clock.tick oneMin
@timer.remaining().should.equal @timer.limit
describe 'resetting a stopped timer', ->
before ->
@timer.reset()
it 'should still be stopped', ->
@timer.state().path().should.equal 'inactive.stopped'
describe 'starting the timer', ->
before ->
$json.stopped = @timer.toJSON()
@timer.start()
$json.started = @timer.toJSON()
it 'should be in the active state', ->
@timer.state().path().should.equal 'active'
it 'should have set the _endTime correctly', ->
should.exist @timer._endTime
@timer._endTime.should.equal Date.now() + @timer.limit
it 'should have added a setTimeout instance on the model', ->
should.exist @timer._timeout
it 'should have added a setInterval instance on the model', ->
should.exist @timer._interval
describe 'timer is active', ->
before ->
@spy = sinon.spy()
@timer.on 'tick', @spy
@clock.tick(fiveSecs) # 5 seconds ahead in time
it 'should return the _endTime as the deadline', ->
@timer.deadline().should.equal @timer._endTime
it 'should subtract the time remaining correctly', ->
@timer.remaining().should.equal twentySecs + fiveSecs
it 'should have emitted tick events', ->
@spy.callCount.should.equal 50
after ->
@timer.off 'tick', @spy
@clock.tick fiveSecs # move another 5 seconds up in time
describe 'pausing the timer', ->
before ->
@deadline = @timer.deadline()
@remaining = @timer.remaining()
@timer.pause()
$json.paused = @timer.toJSON()
@clock.tick fiveSecs # move another 5 seconds up in time
it 'should be in paused state', ->
@timer.state().path().should.equal 'inactive.paused'
it 'should have cleared the _endTime', ->
should.not.exist @timer._endTime
it 'should have cleared the setInterval instance', ->
should.not.exist @timer._interval
it 'should have cleared the setTimeout instance', ->
should.not.exist @timer._timeout
it 'remaining be fixed at what it was when paused', ->
@timer.remaining().should.equal @remaining
it 'deadline should always be remaining ms from now', ->
@timer.deadline().should.equal @remaining + Date.now()
@clock.tick fiveSecs # move another 5 seconds up in time
@timer.deadline().should.equal @remaining + Date.now()
describe 'resuming the timer', ->
before ->
@deadline = @timer.deadline()
@remaining = @timer.remaining()
@timer.resume()
it 'should be in active state', ->
@timer.state().path().should.equal 'active'
describe 'once the timer is active again', ->
before ->
@remaining = @timer.remaining()
@clock.tick(fiveSecs) # move another 5 seconds up in time
it 'should return the _endTime as the deadline', ->
@timer.deadline().should.equal @timer._endTime
it 'should subtract the time remaining correctly', ->
@timer.remaining().should.equal @remaining - fiveSecs
describe 'stopping the timer', ->
before ->
@timer.stop()
it 'should have reached the stopped state', ->
@timer.state().path().should.equal 'inactive.stopped'
it 'should have cleared the _endTime', ->
should.not.exist @timer._endTime
it 'should have cleared the interval/timeout instances', ->
should.not.exist @timer._timeout
should.not.exist @timer._interval
it 'should return the limit as remaining()', ->
@timer.limit.should.equal @timer.remaining()
it 'should always return limit ms from now as the deadline', ->
@timer.deadline().should.equal @timer.limit + Date.now()
@clock.tick fiveSecs # move another 5 seconds up in time
@timer.deadline().should.equal @timer.limit + Date.now()
describe 'resetting the timer', ->
before ->
@timer.start()
@clock.tick(tenSecs)
@timer.reset()
it 'should still be active after being reset', ->
@timer.state().path().should.equal 'active'
it 'should have reset the remaining time', ->
@timer.remaining().should.equal thirtySecs
@clock.tick fiveSecs
@timer.remaining().should.equal twentySecs + fiveSecs
it 'should always return the _endTime as the deadline()', ->
@timer.deadline().should.equal @timer._endTime
@clock.tick fiveSecs
@timer.deadline().should.equal @timer._endTime
describe 'trigger the timeout', ->
before ->
@spy = sinon.spy()
@timer.on 'end', @spy
@clock.tick(twentySecs)
it 'should have triggered the end event', ->
@spy.called.should.be.ok
it 'should be in stopped state', ->
@timer.state().path().should.equal 'inactive.stopped'
after ->
@timer.off 'end', @spy
describe 'serializing the timer', ->
describe 'stopped timers', ->
it 'got serialized', ->
should.exist $json.stopped
it 'have a state', ->
should.exist $json.stopped._state
$json.stopped._state.should.equal 'inactive.stopped'
it 'have a limit', ->
should.exist $json.stopped.limit
$json.stopped.limit.should.equal thirtySecs
it 'dont have an endTime or remaining', ->
should.not.exist $json.stopped._endTime
should.not.exist $json.stopped._remaining
describe 'started timers', ->
it 'got serialized', ->
should.exist $json.started
it 'have a state', ->
should.exist $json.started._state
$json.started._state.should.equal 'active'
it 'have a limit', ->
should.exist $json.started.limit
$json.started.limit.should.equal thirtySecs
it 'have an endTime and remaining', ->
should.exist $json.started._endTime
should.exist $json.started._remaining
describe 'paused timers', ->
it 'got serialized', ->
should.exist $json.paused
it 'have a state', ->
should.exist $json.paused._state
$json.paused._state.should.equal 'inactive.paused'
it 'have a limit', ->
should.exist $json.paused.limit
$json.paused.limit.should.equal thirtySecs
it 'have _remaining', ->
should.exist $json.paused._remaining
it 'not have _endTime', ->
should.not.exist $json.paused._endTime
describe 'initializing the timer', ->
describe 'stopped timers', ->
before ->
@timer = new Models.Timer($json.stopped)
it 'have received their state', ->
@timer.state().path().should.equal 'inactive.stopped'
it 'should have all their correct attributes', ->
@timer.limit.should.equal thirtySecs
should.not.exist @timer._endTime
should.not.exist @timer._remaining
describe 'started timers', ->
before ->
@timer = new Models.Timer($json.started)
it 'have received their state', ->
@timer.state().path().should.equal 'active'
it 'should have all their correct attributes', ->
@timer.limit.should.equal thirtySecs
@timer._endTime.should.equal $json.started._endTime
@timer._remaining.should.equal $json.started._remaining
describe 'paused timers', ->
before ->
@timer = new Models.Timer($json.paused)
it 'have received their state', ->
@timer.state().path().should.equal 'inactive.paused'
it 'should have all their correct attributes', ->
@timer.limit.should.equal thirtySecs
should.not.exist @timer._endTime
@timer._remaining.should.equal $json.paused._remaining
after ->
@clock.restore()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment