Skip to content

Game Engine

Angel Rey edited this page Dec 23, 2020 · 1 revision

Dispatch cycle -- overview

At each level of message processing below the handlers, all state change is handled by a dispatch function. The flow of information is uni-directional and heirarchical--that is, a system can never cause state changes at the level above it. Information starts as an Action, and gets broken down into Events before finally reaching the Models. After all the effects of an Action have been committed to the database, an UPDATE_GAMESTATE message is broadcast back out to any observers or Players. Below is a more detailed account of that process.

  • A User sends a message to the backend via their websocket. It is handled in handlers.py and added to the heartbeat queue. The message consists of a user (which later maps to a Player, a type (which later maps to Action), and some **kwargs.
  • The message is eventually dequeued by a poker game's process via the tablebeat_loop in tablebeat.py. This passes the message down to the controllers.py level.
  • The PokerController's dispatch has involves a number of steps:
    • The message is coerced into an Action (an Enum defined in constants.py), and passed on to player_dispatch.
    • player_dispatch validates that the action was available to the Player, and then calls a method of the same name with whatever arguments were passed in. All Action methods are pure functions which produce a series of Event tuples, which include a (subj, event, kwargs). If Action tuples define the external interface of the poker engine, Event tuples define the interface of the underlying models.
    • The Action and any accompanying **kwargs are written to the HandHistoryLog.
    • The Event list produced by the Action method is passed into internal_dispatch, which has its own sub-steps:
      • Each subj is either a database models (defined in models.py), or the SIDE_EFFECT_SUBJ (which means it triggers something in a subscriber--more on that later). The model objects define the way in which they will be modified by a given event with a member method of the same name, except starting with the string on_. For example, the RAISE_TO event on the Player model invokes its on_raise_to(amt) function.
      • Each Event is also passed to a list of subscriber classes, which are pure listeners. Among these is the AnimationSubscriber, which defines the way a set of backend changes will end up being displayed in the front-end. Others include the BankerSubscriber, which creates Transfer objects to represent the movement of chips, and the LogSubscriber, which creates HandHistoryEvent objects in the database to record all Events for later use in a Replayer. For a complete list, see the __init__ function.
    • Once internal_dispatch completes, step() is called, which makes any new changes to the game (e.g. starting a new hand, dealing new cards, etc). Step may also call internal_dispatch a number of times itself.
    • Once all of these changes have been applied, the controller calls commit(), which attempts a database transaction in all poker models and any side effects caused by subscribers. If it fails, all change attempts are dropped.
    • Finally, commit() calls broadcast_to_sockets() as defined in megaphone.py, which accumulates updates from all the subscribers and the accessor, and passes that state to all Player and user (observer) sockets in a message of type 'UPDATE_GAMESTATE'.

Controller and Accessor

The Controller contains any function that deals directly with Event or Action objects. Also, any function that isn't directly called by step() should be pure-functional. That is, setup_hand() makes several state-mutating calls to internal_dispatch, but each of the functions it calls (e.g. sit_in_pending_and_move_blinds(), post_and_deal()) merely return lists of Event-tuples.

The Accessor is the interface between the Controller and the Models. Any function that aggregates or transforms models belongs in the Accessor. Examples include active_players(), which returns the players seated at a table who are actively involved in play. All of the Accessor's functions are read-only.

Messages, Actions and Events

Messages are payloads that come in from the front-end, or go back out. They always include a sender, type, and kwargs. They are currently inconsistently named in the codebase.

Actions are effectively a subset of Messages, and refer specifically to the Controller API for a Player. Currently, the full (Player, Action, kwargs) tuple is often denoted action as a parameter, even though the Action type is specifically the enum that maps to a controller function.

Events take the same form as Actions, except the first field is called subj in most places, and isn't necessarily a Player--it can be a Table or SIDE_EFFECT_SUBJ. The Event enum defines the API of the underlying Models, and additionally, things that can trigger side-effects in subscribers, all of which use SIDE_EFFECT_SUBJ.

Models

In the game engine, models are never written to directly, but instead mutated through "atomic" Events. Subscribers can manage some models directly, such as Transfers, HandHistoryEvents, or Badges, in which case the list of objects that have changed are accumulated and committed to the database in the same transaction as any game-engine events when the Controller calls commit().

Subscribers

All subscribers have a dispatch() function, which is called every time an Event passes through the Controller's internal_dispatch. Additionally, they all have a commit(), which is called by the controller inside of a Transacaction. Finally, each has an updates_for_broadcast() function which takes a player and returns any information that should be added to the 'UPDATE_GAMESTATE' Message at the end of a dispatch cycle.

Animations

All the the Animations that are played in the front-end are specified in the back-end, by the AnimationSubscriber. They are defined by the AnimationEvent enum in constants.py. Because AnimationEvents to not map one-to-one with Events, there is a somewhat complicated reduction process that has to take place inside of animations.py.

Each 'UPDATE_GAMESATE' message gets a group of Animations, which begin with a 'SNAPTO' and also end with a 'SNAPTO', which update the frontend state completely, and provide a fallback in case of frontend errors or packet loss.

Logging

The DBLog, defined in handhistory.py, is used by a LogSubscriber to log all Events that pass through a Controller's internal dispatch. Each Event-tuple is logged to database using the HandHistoryEvent model.

However, the logging system is unlike other Subscribers in that it also receives every Action as well, in a call to write_action() inside of player_dispatch() on the Controller. These are stored with the HandHistoryAction model.

Both HandHistoryEvent and HandHistoryAction point to a parent, HandHistory, one of which exists for each individual hand played at a table. A new HandHistory object is created each time the NEW_HAND Event is dispatched, and a serialized snapshot of the PokerTable and Player objects are added in JSON format to the database.

This system makes it possible to recreate the state at any point in the history of any table, using the Replayer classes in replayer.py. These are especially useful for debugging.

Bug Reports

The Controller has a report_bug() method which calls its own dump_for_test(). This creates a JSON-serialized set of HandHistory objects, which can be loaded into a Replayer for debugging. Un-comment the CheckWhatTest at the bottom of test_hands.py and run ./manage.py test poker.tests.test_hands.CheckWhatTest to introspect state at a broken point in the history.