-
Notifications
You must be signed in to change notification settings - Fork 15
Architecture Overview
Using an example game setup, let's discuss the primary pieces of Legend of the Green Dragon, Daenerys Edition's core code.
First, let's cover some concepts you'll encounter as you read through this documentation.
A full, playable installation of a Daenerys game has a number of parts. The core contains the main game loop, basic classes required for a functioning game, like Character
and Scene
, and module management code. Unlike the original LotGD codebase, it contains no module code and no "game story" code, like Villages or Forests, etc. It also has no UI, so no way to actually play the game; it is strictly an API for developers to build upon. The code in this repo is the core code, hence the name :)
In conjunction with the core, a Daenerys install has a crate, which wraps the core and is responsible for how the game is presented to the world: showing the game scenes to the user, collecting their navigation actions and passing that to the core's game loop. A simple crate might be a command line app that prints scene descriptions to the console and collects keyboard input, or a web app which renders HTML pages.
In practice, there might be additional layers. For example, the official Daenerys server wraps the core in a GraphQL API crate, but that is still not a playable UI. Our setup then relies on a client that reads/writes to the GraphQL interface. We have web and native mobile client implementations cooking.
We chose this separation of responsibilities to provide more flexibility in how LotGD apps can be built and to support many different UI paradigms, from command line to web to native mobile and beyond. This design also helps insulate the infrequently changing core code from rapidly evolving (and possibly diverging) UI code.
Finally, most of the actual "game" parts of the game, like storyline and interactions between characters, are provided via modules, which are like plugins. Modules can install new data into the game and can change game outcomes by responding to the events delivered by the crate, core or other modules (see Events and Hooks below).
Throughout the code, you'll frequently encounter the Game
class, often with an instance called $g
. Game
provides access to the database, instances of controllers, and support for the game loop. Pretty much all operations go through the Game
instance.
To facilitate communication between the crate, the core, and modules, the core contains a publish/subscribe communication channel. Any piece of code can subscribe to events via the $g->getEventManager()->subscribe()
method call by providing an event name, which is a string. Whenever that event occurs, the registered code's handleEvent($event, $context)
method will be called with any context array the calling code provides. The subscriber can then choose to take any action it likes, including modifying the context.
One common type of event is called a hook, which is just an event where the caller expects a response, usually provided via the context. For example, there is a hook called h/lotgd/core/default-scene
that is triggered when the core wants to know where the starting scene for users is, as in, where a newly-created user should be placed once they sign up. It expects a Scene
object to be placed into $context['scene']
as a response.
For the purposes of this overview, let's talk about a realm (or world) centered around a simplified representation of the Lonely Mountain from JRR Tolkien's The Hobbit. See the diagram below representing the places, or scenes as we call them, where the user can go in this world:
Lonely Mountain ─────── Mirkwood
│
┌─────────┴─────────┐
│ │
│ │
Throne Room Forges
│
│
┌─────┴──────┐
│ │
│ │
Traveling Thorin
Elves
Some notes about gameplay in this realm:
- In the "Forges", the user can buy weapons and armor for her battles in the forest.
- The traveling elves are not always visiting the "Throne Room", and so may not be available every time the user visits.
- In "Mirkwood", the user can find and fight beasts, for experience and gold.
Every location the user can visit in the game, and even every "screen" displayed, has a corresponding Scene
model in the database. The basic data about a scene is stored in this model, like a description to display to the user and the connections to nearby scenes. We chose this data-driven approach to organizing scene data to allow game administrators to create, move and modify scenes in their realm without making code modifications. Imagine a module that provides a generic "Inn" scene. Once installed, the game admin can create any number of instances of this Inn scene by making copies and connecting copies to each one of their villages, making appropriate text changes along the way.
Note that when we say every "screen" needs a corresponding Scene
model, we mean not only physical locations, like the "Forges", but also "logical" locations, like the buying or selling screens within the "Forges". Again, storing the text inside the database allows for easy customization of these scenes without code modifications.
To the user, a scene has a description and a navigation menu. For example, the Lonely Mountain scene might have the following:
- Description: "A cavernous interior opens before your eyes, filled with massive pillars of stone. You hear distant hammering and dwarves scurry this way and that."
- Navigation Menu:
- (T)hrone Room
- (F)orges
- Exit to (M)irkwood
When a user visits a location in the game, the corresponding Scene
object's description, list of children and parents, etc. are fetched from the database. The navigation menu is then generated from the list of children and parents. The resulting data is stored in the database as a Viewpoint
, representing one user's visit to a scene. Module code can modify this description or navigation menu (or any other part of the Viewpoint
) when the Scene
is created (see how we implement the Visiting Elves below). However, once generated, the Viewpoint
is fixed for that user's visit to the scene.
Get the current user's viewpoint via $g->getViewpoint()
.
Items in the navigation menu are instances of the Action
class and are grouped into instances of the ActionGroup
class. Both Action
and ActionGroup
have unique identifiers to help in referencing them. Every Viewpoint
has at least one ActionGroup
with the ID ActionGroup::DefaultGroup
. By default, navigation items targeting the user's previous scene and all children of the active scene will be placed in the DefaultGroup
.
To register a navigation action, the crate should call $g->takeAction($id, $parameters)
where $id
is the action's identifier, reachable via the getId()
method on Action
. $parameters
is an optional associative array of parameters available to modules responding to the navigation hooks.
So far we've seen how static description and navigation items are created from the data stored in the database and stored in Viewpoint
s. What about dynamic content, like conditional navigation items or descriptions that change based on circumstances (think about the town clock, for example)? Let's talk about how we can implement these features using the navigation hooks.
When a Viewpoint
is created, a hook is published called h/lotgd/core/navigate-to/[scene-template]
, where [scene-template]
is $scene->getTemplate()
. We haven't talked about templates yet, but each Scene
object has a template property that represents the "type" of scene it is. For example, maybe there's another mountain in our game; let's call it "Mt. Doom." Both it and "The Lonely Mountain" might share the same template, because maybe they have a lot of the same elements or come from the same module. The template itself might be something like lotgd/wiki/mountain
. When the user navigates to either "Mt. Doom" or "The Lonely Mountain", a hook called h/lotgd/core/navigate-to/lotgd/wiki/mountain
would be published.
Subscribers to this navigate-to
hook are passed a context like this:
[
'referrer' => $referrer,
'viewpoint' => $viewpoint,
'scene' => $scene,
'parameters' => $parameters,
'redirect' => null
]
This context gives subscribers the opportunity to modify the $viewpoint
based on data stored within the current $scene
, any navigation $parameters
passed through, and the previous scene, passed as $referrer
. Subscribers can even redirect to another scene by setting $redirect
to another scene, but this action is out of scope for this overview.
Let's suppose we want to display the "Visiting Elves" navigation option inside the "Throne Room" only on even days. The "Visiting Elves" module subscribes to h/lotgd/core/navigate-to/lotgd/wiki/throne-room
, so it can modify the "Throne Room" menu. Inside its event handler, there would be code like this:
public static function handleEvent(Game $g, string $event, array &$context)
{
// handleEvent() covers all subscribed events, so you need to check for
// a specific one.
switch ($event) {
case 'h/lotgd/core/navigate-to/lotgd/wiki/throne-room':
$viewpoint = $context['viewpoint'];
if ($g->getTimeKeeper()->gameTime()->format('z') % 2 == 0) { // is this an even game day?
$visitingElvesScene = self::findVisitingElvesScene(); // convenience function to find the elves scene, more on this later
$viewpoint->removeActionsWithSceneId($visitingElvesScene->getId());
}
break;
}
}
This code removes the "Visiting Elves" scene from the "Throne Room" menu on even days.
A note about self::findVisitingElvesScene()
. To find the specific instance of Scene
that represents the "Visiting Elves" location in the game, we could use a few mechanisms:
- We could search the scenes in the actions and look for one with the right template, though there may be more than one if the user duplicates the scene, etc.
- When the module is created, we can create a "Visiting Elves"
Scene
object and store its ID somewhere, then fetch it insideself::findVisitingElvesScene()
. This our preferred method and module models have a set of methods calledsetProperty()
andgetProperty()
to help with this. See the example in theonRegister()
method inside the Weapons Shop module.
We've seen how modules can modify the $viewpoint
, including changing navigation items. But a description and navigation menu is simply too restrictive for the rich set of features we envision for the game, so each $viewpoint
can have attachments as well, which are arbitrary associative arrays.
In our example realm, we will implement the "Forges" scene's ability to buy weapons and armor via this attachment scheme using the Forms module. Forms provides a simple, HTML-inspired form model complete with a list of elements, some text for a submit button, and an Action
object for that submit button. Note that because only actions present in the navigation menu can be used to move the user from scene to scene, the Forms module adds its submit action to a special hidden ActionGroup
with ID ActionGroup::HiddenGroup
.
See the addForSaleForm()
method in the Weapons Shop module for a full example of this kind of attachment.
Note that attachments are simply associative arrays, and so in true Daenerys fashion, it is up to the crate to determine how to display attachments and process user interactions with them. In our "Forges" case, presumably an HTML crate would render an HTML <form>
tag.