regal
v2.0.0
Published
TypeScript package for games developed in the Regal framework.
Downloads
203
Maintainers
Readme
Regal
Introduction
The Regal Game Library is the official TypeScript library for the Regal Framework, a project designed to help developers bring text-driven games and story experiences to players in exciting new ways.
What is the Regal Framework?
The Regal Framework is a set of tools for developers to create text-driven games, sometimes called Interactive Fiction (IF), as pure functions.
For more information, check out the project's about page.
How is the Regal Game Library Used?
The Regal Game Library, often referred to as the Game Library or its package name regal
, is the JavaScript library that game developers use to create games for the Regal Framework. It was designed with first-class support for TypeScript, but doesn't require it.
When a game is created using the Regal Game Library, it can be played by any Regal client automatically.
What's the point?
Similar to Java's "write once, run anywhere" mantra, the goal of the Regal Framework is to produce games that can be played on all kinds of platforms without needing to rewrite any code.
The name Regal is an acronym for Reinventing Gameplay through Audio and Language. The project was inspired by the idea of playing adventure games on smart voice assistants, but it doesn't have to stop there. Chatrooms, consoles, smart fridges...they're all within reach!
Table of Contents
- Introduction
- Installation
- Project Roadmap
- Contributing
- Guide: Creating Your First Regal Game
- Documentation
- API Reference
Installation
regal
is available on npm and can be installed with the following command:
npm install regal
If you're using TypeScript (highly recommended), import it into your files like so:
import { GameInstance } from "regal";
Otherwise, using Node's require
works as well:
const regal = require("regal");
Project Roadmap
The Regal Game Library has been in development since June 2018. The first stable version, alias Beacon, was released on February 1st, 2019.
Regal 2.0.0
, alias Dakota, was released on November 11th, 2019. This version included some sweeping refactors that fixed bugs in the initial release, namely adding the Agent Prototype Registry to support class methods for Agents.
Moving forward, the most pressing features that should be added to the Game Library are the player command and plugin interfaces.
Outside of the library, other priorities include:
- Improving the development tooling surrounding the framework, such as expanding regal-bundler and creating a CLI.
- Building clients to play Regal games on various platforms.
- Creating fun Regal games.
Contributing
Currently, the Regal Framework is developed solely by Joe Cowman (jcowman2), but pull requests, bug reports, suggestions, and questions are all more than welcome!
If you would like to get involved, please see the contributing page or the project's about page.
Guide: Creating Your First Regal Game
The following is a step-by-step guide for creating a basic game of Rock, Paper, Scissors with Regal and TypeScript.
For more detailed information on any topic, see the API Reference below. Everything in this guide is available in the Regal demos repository as well.
Step 1. Set up project
Start with an empty folder. Create a package.json
file in your project's root directory with at least the following properties:
{
"name": "my-first-game",
"author": "Your Name Here"
}
Then, install the regal
dependency.
npm install regal
Since your game will be written in TypeScript (as is recommended for all Regal games), you'll need to install typescript
as well:
npm install --save-dev typescript
Create a src
directory and a new file called index.ts
inside it. This is where you'll write your game logic.
At this point, your project should have the following structure:
.
├── node_modules
├── package.json
├── package-lock.json
└── src
└── index.ts
Step 2. Write game logic
In index.ts
, place the following import statement on the top line:
import { onPlayerCommand, onStartCommand } from "regal";
The Regal Game Library has way more tools to help you make games, but these imports are all you need for a game this basic.
Beneath the import line, paste the following constants. You'll use these when writing the game's logic. WIN_TABLE
is a lookup table to see if one move beats another. For example, WIN_TABLE.paper.scissors
is false
, since paper loses to scissors.
const POSSIBLE_MOVES = ["rock", "paper", "scissors"];
const WIN_TABLE = {
rock: {
paper: false,
scissors: true
},
paper: {
rock: true,
scissors: false
},
scissors: {
rock: false,
paper: true
}
}
Next, you'll set the game's start behavior with onStartCommand
. When a player starts a new game, both the player's and the opponent's scores will be initialized to zero, and a prompt will be displayed. Paste the following block of code beneath your constants:
onStartCommand(game => {
// Initialize state
game.state.playerWins = 0;
game.state.opponentWins = 0;
// Prompt the player
game.output.write("Play rock, paper, or scissors:");
});
Finally, you need the actual gameplay. The following block should be pasted at the end of your file. It contains the behavior that runs every time the player enters a command.
onPlayerCommand(command => game => {
// Sanitize the player's command
const playerMove = command.toLowerCase().trim();
// Make sure the command is valid
if (POSSIBLE_MOVES.includes(playerMove)) {
// Choose a move for the opponent
const opponentMove = game.random.choice(POSSIBLE_MOVES);
game.output.write(`The opponent plays ${opponentMove}.`);
if (playerMove === opponentMove) {
game.output.write("It's a tie!");
} else {
// Look up who wins in the win table
const isPlayerWin = WIN_TABLE[playerMove][opponentMove];
if (isPlayerWin) {
game.output.write(`Your ${playerMove} beats the opponent's ${opponentMove}!`);
game.state.playerWins++;
} else {
game.output.write(`The opponent's ${opponentMove} beats your ${playerMove}...`);
game.state.opponentWins++;
}
}
// Print win totals
game.output.write(
`Your wins: ${game.state.playerWins}. The opponent's wins: ${game.state.opponentWins}`
);
} else {
// Print an error message if the command isn't rock, paper, or scissors
game.output.write(`I don't understand that command: ${playerMove}.`);
}
// Prompt the player again
game.output.write("Play rock, paper, or scissors:");
});
One last thing: the line if (POSSIBLE_MOVES.includes(playerMove)) {
uses Array.prototype.includes
, which is new in ECMAScript 2016. To make the TypeScript compiler compatible with this, add a tsconfig.json
file to your project's root directory with the following contents:
{
"compilerOptions": {
"lib": ["es2016"]
}
}
Step 3. Bundle game
Before your game can be played, it must be bundled. Bundling is the process of converting a Regal game's development source (i.e. the TypeScript or JavaScript source files that the game developer writes) into a game bundle, which is a self-contained file that contains all the code necessary to play the game via a single API.
You can use the Regal CLI to create Regal game bundles from the command line. Install it like so:
npm install -g regal-cli regal
To bundle your game, execute this command in your project's root directory:
regal bundle
This should generate a new file in your project's directory, called my-first-game.regal.js
. Your first game is bundled and ready to be played!
For a list of configuration options you can use, consult the CLI's documentation.
Step 4. Play game
To load a Regal game bundle for playing, use the Regal CLI play
command.
regal play my-first-game.regal.js
The game should open in your terminal. Enter rock
, paper
, or scissors
to play, or :quit
to exit the game. The sequence should look something like this:
Now Playing: my-first-game by Your Name Here
Type :quit to exit the game.
Play rock, paper, or scissors:
paper
The opponent plays rock.
Your paper beats the opponent's rock!
Your wins: 1. The opponent's wins: 0
Play rock, paper, or scissors:
Congratulations, you've created your first game with the Regal Framework! :tada:
Documentation
The following sections provide a guide to each aspect of the Regal Game Library. For detailed information on a specific item, consult the API Reference.
Core Concepts
The Regal Game Library is a JavaScript package that is required by games to be used within the Regal Framework. A game that is built using the Game Library is called a Regal game.
Regal games have the following qualities:
- They are text-based. Simply put, gameplay consists of the player putting text in and the game sending text back in response.
- They are deterministic. When a Regal game is given some input, it should return the same output every time. (To see how this applies to random values, read here.)
These two qualities allow Regal games to be thought of as pure functions. A pure function is a function that is deterministic and has no side-effects. In other words, a Regal game is totally self-contained and predictable.
Think of playing a Regal game like the following equation:
g1(x) = g2
where x is the player's command
g1 is the Regal game before the command
g2 is the Regal game after the command
Entering the player's command into the first game instance creates another game instance with the effects of the player's command applied. For example, if g1
contains a scene where a player is fighting an orc, and x
is "stab orc"
, g2
might show the player killing that orc. Note that g1
is unmodified by the player's command.
The process of one game instance interpreting a command and outputting another game instance is called a game cycle.
Game Data
All data in a Regal game is in one of two forms: static or instance-specific.
Static data is defined in the game's source code, and is the same for every instance of the game. Game events, for example, are considered static because they are defined the same way for everyone playing the game (even though they may have different effects). Metadata values for the game, such as its title and author, are also static.
Instance-specific data, more frequently called game state, is unique to a single instance of the game. A common example of game state is a player's stats, such as health or experience. Because this data is unique to one player of the game and is not shared by all players, it's considered instance-specific.
Understanding the difference between static data and game state is important. Everything that's declared in a Regal game will be in one of these two contexts.
GameInstance
The cornerstone of the Regal Game Library is the GameInstance
.
A GameInstance
represents a unique instance of a Regal game. It contains (1) the game's current state and (2) all the interfaces used to interact with the game during a game cycle.
GameInstance vs Game
To understand how a game instance differs from the game itself, it can be helpful to think of a Regal game like a class. The game's static context is like a class definition, which contains all the immutable events and constants that are the same for every player.
When a player starts a new Regal game, they receive an object of that class. This game instance is a snapshot of the Regal game that is unique to that player. It contains the effects of every command made by the player, and has no bearing on any other players' game instances.
Two people playing different games of Solitare are playing the same game, but different game instances.
Instance State
All game state is stored in a GameInstance
object. Some of this state is hidden from view, but custom properties can be set directly in GameInstance.state
.
// Assumes there's a GameInstance called myGame
myGame.state.foo = "bar";
myGame.state.arr = [1, 2, 3];
Properties set within the state
object are maintained between game cycles, so it can be used to store game data long-term.
GameInstance.state
is of of type any
, meaning that its properties are totally customizable. Optionally, the state type may be set using a type parameter (see Using StateType for more information).
InstanceX Interfaces
In addition to storing game state, GameInstance
contains several interfaces for controlling the game instance's behavior.
Property | Type | Controls
--- | --- | ---
events
| InstanceEvents
| Events
output
| InstanceOutput
| Output
options
| InstanceOptions
| Options
random
| InstanceRandom
| Randomness
state
| any
or StateType
| Miscellaneous state
Each of these interfaces is described in more detail below.
Using StateType
GameInstance.state
is of of type any
, meaning that its properties are totally customizable. Optionally, the state type may be set using a type parameter called StateType
.
The StateType
parameter allows you to type-check the structure of GameInstance.state
against a custom interface anywhere GameInstance
is used.
interface MyState {
foo: boolean;
}
(myGame: GameInstance<MyState>) => {
const a = myGame.state.foo; // Compiles
const b = myGame.state.bar; // Won't compile!
};
Keep in mind that StateType
is strictly a compile-time check, and no steps are taken to ensure that the state
object actually matches the structure of StateType
at runtime.
(myGame: GameInstance<string>) => myGame.state.substring(2); // Compiles fine, but will throw an error at runtime because state is an object.
StateType
is especially useful for parameterizing events.
Events
A game where everything stays the same isn't much of a game. Therefore, Regal games are powered by events.
An event can be thought of as anything that happens when someone plays a Regal game. Any time the game's state changes, it happens inside of an event.
Event Functions
Events in the Regal Game Library share a common type: EventFunction
.
An EventFunction
takes a GameInstance
as its only argument, modifies it, and may return the next EventFunction
to be executed, if one exists.
Here is a simplified declaration of the EventFunction
type:
type EventFunction = (game: GameInstance) => EventFunction | void;
An EventFunction
can be invoked by passing it a GameInstance
:
// Assumes there's a GameInstance called myGame
// and an EventFunction called event1, which returns void.
event1(myGame); // Invoke the event.
// Now, myGame contains the changes made by event1.
EventFunction
has two subtypes: TrackedEvent
and EventQueue
. Both are described below.
Declaring Events
The most common type of event used in Regal games is the TrackedEvent
. A TrackedEvent
is simply an EventFunction
that is tracked by the GameInstance
.
In order for Regal to work properly, all modifications to game state should take place inside tracked events.
To declare a TrackedEvent
, use the on
function:
import { on } from "regal";
const greet = on("GREET", game => {
game.output.write("Hello, world!");
});
The on
function takes an event name and an event function to construct a TrackedEvent
. The event declared above could be invoked like this:
// Assumes there's a GameInstance called myGame.
greet(myGame); // Invoke the greet event.
This would cause myGame
to have the following output:
GREET: Hello, world!
Causing Additional Events
As stated earlier, an EventFunction
may return another EventFunction
. This tells the event executor that another event should be executed on the game instance.
Here's an example:
const day = on("DAY", game => {
game.output.write("The sun shines brightly overhead.");
});
const night = on("NIGHT", game => {
game.output.write("The moon glows softly overhead.");
});
const outside = on("OUTSIDE", game => {
game.output.write("You go outside.");
return game.state.isDay ? day : night;
});
When the outside
event is executed, it checks the value of game.state.isDay
and returns the appropriate event to be executed next.
// Assume that myGame.state.isDay is false.
outside(myGame);
myGame
's output would look like this:
OUTSIDE: You go outside.
NIGHT: The moon glows softly overhead.
Causing Multiple Events
It's possible to have one EventFunction
cause multiple events with the use of an EventQueue
.
An EventQueue
is a special type of TrackedEvent
that contains a collection of events. These events are executed sequentially when the EventQueue
is invoked.
Queued events may be immediate or delayed, depending on when you want them to be executed.
Immediate Execution
To have one event be executed immediately after another, use the TrackedEvent.then()
method. This is useful in situations where multiple events should be executed in direct sequence.
To demonstrate, here's an example of a player crafting a sword. When the makeSword
event is executed, the sword is immediately added to the player's inventory (addItemToInventory
) and the player learns the blacksmithing skill (learnSkill
).
const learnSkill = (name: string, skill: string) =>
on(`LEARN SKILL <${skill}>`, game => {
game.output.write(`${name} learned ${skill}!`);
});
const addItemToInventory = (name: string, item: string) =>
on(`ADD ITEM <${item}>`, game => {
game.output.write(`Added ${item} to ${name}'s inventory.`);
});
const makeSword = (name: string) =>
on(`MAKE SWORD`, game => {
game.output.write(`${name} made a sword!`);
return learnSkill(name, "Blacksmithing")
.then(addItemToInventory(name, "Sword"));
});
Note: This example is available here.
Execute the makeSword
event on a GameInstance
called myGame
like so:
makeSword("King Arthur")(myGame);
This would produce the following output for myGame
:
MAKE SWORD: King Arthur made a sword!
ADD ITEM <Sword>: Added Sword to King Arthur's inventory.
LEARN SKILL <Blacksmithing>: King Arthur learned Blacksmithing!
Delayed Execution
Alternatively, an event may be scheduled to execute only after all of the immediate events are finished by using enqueue()
. This is useful in situations where you have multiple series of events, and you want each series to execute their events in the same "round."
This is best illustrated with an example. Here's a situation where a player executes a command that drops a list of items from their inventory.
import { on, enqueue } from "regal";
const hitGround = (item: string) =>
on(`HIT GROUND <${item}>`, game => {
game.output.write(`${item} hits the ground. Thud!`);
});
const fall = (item: string) =>
on(`FALL <${item}>`, game => {
game.output.write(`${item} falls.`);
return enqueue(hitGround(item));
});
const drop = on("DROP ITEMS", game => {
let q = enqueue();
for (let item of game.state.items) {
q = q.enqueue(fall(item));
}
return q;
});
Note: This example is available here.
We'll walk through each line, starting from the drop
function.
const drop = on("DROP ITEMS", game => {
The enqueue()
function takes zero or more TrackedEvent
s as arguments, which it uses to build an EventQueue
. Creating an empty queue has no effect; it simply provides us a reference to which we can add additional events.
let q = enqueue();
In addition to being a standalone function, enqueue()
is also a method of EventQueue
. Calling EventQueue.enqueue()
creates a new queue with all previous events plus the new event(s).
for (let item of game.state.items) {
q = q.enqueue(fall(item));
}
The previous two code blocks could be simplified by using JavaScript's map
like so:
const q = enqueue(game.state.items.map(item => fall(item)));
Finally, we return the event queue.
return q;
});
The fall
event is simpler. It outputs a message and adds a hitGround
event to the end of the queue for a single item.
const fall = (item: string) =>
on(`FALL <${item}>`, game => {
game.output.write(`${item} falls.`);
return enqueue(hitGround(item));
});
The hitGround
event outputs a final message.
const hitGround = (item: string) =>
on(`HIT GROUND <${item}>`, game => {
game.output.write(`${item} hits the ground. Thud!`);
});
Deciding that game.state.items
contains ["Hat", "Duck", "Spoon"]
, executing the drop
event would produce an output like this:
FALL <Hat>: Hat falls.
FALL <Duck>: Duck falls.
FALL <Spoon>: Spoon falls.
HIT GROUND <Hat>: Hat hits the ground. Thud!
HIT GROUND <Duck>: Duck hits the ground. Thud!
HIT GROUND <Spoon>: Spoon hits the ground. Thud!
If you're still confused about the difference between TrackedEvent.then()
and EventQueue.enqueue()
, here's what the output would have been if TrackedEvent.then()
was used instead:
FALL <Hat>: Hat falls.
HIT GROUND <Hat>: Hat hits the ground. Thud!
FALL <Duck>: Duck falls.
HIT GROUND <Duck>: Duck hits the ground. Thud!
FALL <Spoon>: Spoon falls.
HIT GROUND <Spoon>: Spoon hits the ground. Thud!
Remember, enqueue()
is useful for situations where you have multiple series of events, like our [fall
-> hitGround
] series, and you want each series to execute their alike events in the same "round." We didn't want hat
to finish hitting the ground before duck
fell, we wanted all of the items to fall together and hit the ground together.
TrackedEvent.then()
is for immediate exeuction andenqueue()
is for delayed exeuction.
Event Chains
The event API is chainable, meaning that the queueing methods can be called multiple times to create more complex event chains.
// Immediately executes events 1-4
event1.then(event2, event3, event4);
event1.then(event2).then(event3).then(event4);
event1.then(event2.then(event3, event4));
event1.then(event2, event3.then(event4));
// Immediately executes event1, delays 2-4.
event1.then(enqueue(event2, event3, event4));
event1.thenq(event2, event3, event4); // TrackedEvent.thenq is shorthand for TrackedEvent.then(enqueue(...))
// Immediately executes events 1-2, delays 3-4.
event1.then(event2).enqueue(event3, event4);
event1.then(event2, enqueue(event3, event4));
event1.then(event2).enqueue(event3).enqueue(event4);
// Delays events 1-4.
enqueue(event1, event2, event3, event4);
enqueue(event1.then(event2, event3, event4));
If you prefer, you can use the shorthand nq
instead of writing enqueue()
. We're all about efficiency. :+1:
import { nq } from "regal";
let q = nq(event1, event2);
q = q.nq(event3);
When creating event chains, keep in mind that all immediate events must be added to the queue before delayed events.
event1.then(nq(event2, event3), event4); // ERROR
event1.then(event4, nq(event2, event3)); // Okay
For more information, consult the API Reference.
When to Use noop
noop
is a special TrackedEvent
that stands for no operation. When the event executor runs into noop
, it ignores it.
import { on, noop } from "regal";
const end = on("END", game => {
game.output.write("The end!");
return noop; // Nothing will happen
});
EventFunction
doesn't have a required return, so most of the time you can just return nothing instead of returning noop
.
const end = on("END", game => {
game.output.write("The end!");
});
However, noop
might be necessary in cases where you want to use a ternary to make things simpler:
on("EVENT", game =>
// Return another event to be executed if some condition holds, otherwise stop.
game.state.someCondition ? otherEvent | noop;
);
If you have a TypeScript project with noImplicitReturns
enabled, noop
is useful for making sure all code paths return a value.
// Error: [ts] Not all code paths return a value.
on("EVENT", game => {
if (game.state.someCondition) {
return otherEvent;
}
});
// Will work as intended
on("EVENT", game => {
if (game.state.someCondition) {
return otherEvent;
}
return noop;
});
Parameterizing Events
The StateType
type parameter of GameInstance
can be used to declare the type of GameInstance.state
inside the scope of individual events.
EventFunction
, TrackedEvent
, and EventQueue
all allow an optional type parameter that, if used, will type-check GameInstance.state
inside the body of the event.
import { on } from "regal";
interface MyState {
num: number;
names: string[];
}
const init = on<MyState>("INIT", game => {
game.state.num = 0; // Type checked!
game.state.names = ["spot", "buddy", "lucky"]; // Type checked!
});
const pick = on<MyState>("PICK", game => {
const choice = game.state.names[game.state.num]; // Type checked!
game.output.write(`You picked ${choice}!`);
game.state.num++; // Type checked!
});
Including the type parameter for every event gets unwieldy for games with many events, so the type GameEventBuilder
can be used to alias a customized on
.
import { on as _on, GameEventBuilder } from "regal";
interface MyState {
num: number;
names: string[];
}
const on: GameEventBuilder<MyState> = _on; // Declare `on` as our parameterized version
const init = on("INIT", game => {
game.state.num = 0; // Type checked!
game.state.names = ["spot", "buddy", "lucky"]; // Type checked!
});
const pick = on("PICK", game => {
const choice = game.state.names[game.state.num]; // Type checked!
game.output.write(`You picked ${choice}!`);
game.state.num++; // Type checked!
});
Note: This example is available here.
Using the redefined on
from this example, the following event would not compile:
on("BAD", game => {
game.state.nams = []; // Error: [ts] Property 'nams' does not exist on type 'MyState'. Did you mean 'names'?
});
Agents
Where events describe any change that occurs within a Regal game, agents are the objects on which these changes take place.
Every object that contains game state (like players, items, and score) is considered an agent.
Defining Agents
The Regal Game Library offers the Agent
class, which you can extend to create custom agents for your game.
Here is an example:
import { Agent } from "regal";
class Bucket extends Agent {
constructor(
public size: number,
public contents: string,
public isFull: boolean
) {
super();
}
}
Now, a Bucket
can be instantiated and used just like any other class.
const bucket = new Bucket(5, "water", true);
bucket.size === 5; // True
Furthermore, you can use them in events.
const init = on("INIT", game => {
game.state.bucket = new Bucket(5, "famous chili", true);
});
const pour = on("POUR", game => {
const bucket: Bucket = game.state.bucket;
if (bucket.isFull) {
bucket.isFull = false;
game.output.write(`You pour out the ${bucket.contents}.`);
} else {
game.output.write("The bucket is already empty!");
}
});
Executing init.then(pour, pour)
on a game instance would give the following output:
POUR: You pour out the famous chili.
POUR: The bucket is already empty!
Note: this example is available here.
Active and Inactive Agents
Agents have a single caveat that may seem strange at first, but is important to understand:
Before an agent's properties can be accessed in a game cycle, the agent must first be activated.
To activate an agent simply means to register it with the GameInstance
. Much like how all changes to game state need to take place inside tracked events, the agents that contain this state need to be tracked as well.
Activating an agent allows it to be tracked by the GameInstance
, and can happen either implicitly or explicitly.
If you try to modify an inactive agent within a game cycle, a RegalError
will be thrown. For example:
const illegalEvent = on("EVENT", game => {
const waterBucket = new Bucket(1, "water", true); // Create an inactive agent
waterBucket.isFull = false; // Uh-oh!
});
Executing illegalEvent
will throw the following error:
RegalError: The properties of an inactive agent cannot be set within a game cycle.
Note: this example is available here.
Activating Agents Implicitly
Most of the time, agents will activate themselves without you having to do any extra work. In fact, you've already seen an example of this with the Bucket
agent above.
One of the ways that agents may be activated implicitly is by setting them as a property of an already-active agent.
const init = on("INIT", game => {
game.state.bucket = new Bucket(5, "famous chili", true);
});
When our new Bucket
agent was assigned to the game instance's state.bucket
property, it was activated implicitly. This is because GameInstance.state
is actually an active agent.
Whenever agents are set as properties of
GameInstance.state
, they are activated implicitly.
There are five ways to activate agents implicitly. The first way, which was demonstrated above, is to set the agent as a property of an active agent.
class Parent extends Agent {
constructor(
public num: number,
public child?: Agent // Optional child property
) {
super();
}
}
game.state.myAgent = new Parent(1); // #1 is activated by GameInstance.state
game.state.myAgent.child = new Parent(2); // #2 is activated by #1
Second, all agents that are properties of an inactive agent are activated when the parent agent is activated.
const p = new Parent(1, new Parent(2)); // #1 and #2 are both inactive
game.state.myAgent = p; // #1 and #2 are activated by GameInstance.state
Third, all agents in arrays that are properties of an inactive agent are activated when the parent agent is activated.
class MultiParent extends Agent {
constructor(
public num: number,
public children: Agent[] = [] // Default to an empty array
) {
super();
}
}
const mp = new MultiParent(1, [ new Parent(2), new Parent(3) ]); // #1, #2, and #3 are inactive
game.state.myAgent = mp; // #1, #2, and #3 are activated by GameInstance.state
Fourth, all agents in an array are activated when it is set as the property of an already-active agent.
game.state.myAgent = new MultiParent(1); // #1 is activated by GameInstance.state
game.state.myAgent.children = [ new Parent(2), new Parent(3) ]; // #2 and #3 are activated by #1
Finally, an agent is activated when it is added to an array that's a property of an already-active agent.
game.state.myAgent = new MultiParent(1, [ new Parent(2) ]); // #1 and #2 are activated by GameInstance.state
game.state.myAgent.children.push(new Parent(3)); // #3 is activated by #1
Note: this example is available here.
Activating Agents Explicitly
Agents can be activated explicitly with GameInstance.using()
.
GameInstance.using()
activates one or more agents and returns references to them. The method takes a single argument, which can be one of a few types.
First, a single agent may be activated.
class CustomAgent extends Agent {
constructor(public num: number) {
super();
}
}
const agent = game.using(new CustomAgent(1)); // #1 is activated
Second, an array of agents may be activated:
const agents = game.using([ new CustomAgent(1), new CustomAgent(2) ]); // #1 and #2 are activated
Finally, the argument can be an object where every property is an agent to be activated:
const { agent1, agent2 } = game.using({
agent1: new CustomAgent(1),
agent2: new CustomAgent(2)
}); // #1 and #2 are activated
Note: this example is available here.
Explicit activation is useful for situations where agents aren't being activated implicitly. If you can't figure out whether an agent is getting activated implicitly or not, you may want to explicitly use GameInstance.using()
just to be safe. Activating an agent multiple times has no effect.
Modifying Inactive Agents
Inactive agents aren't truly immutable. In fact, any property of an inactive agent may be set once before the agent is activated. Otherwise, setting properties within constructors wouldn't be possible.
Technically, this means doing something like this is valid (although not recommended):
const a = new CustomAgent(1);
(a as any).someOtherProperty = "foo";
game.state.myAgent = a;
It's important to note that the properties of inactive agents are only inaccessible within the context of a game cycle (i.e. inside a TrackedEvent
). When agents are declared outside of events, they are called static agents and have special characteristics. These are explained in the next section.
Static Agents
All Regal game data is considered either static or instance-specific. The agents we've created up to this point have all been instance-specific, meaning that they are defined inside events and their data is stored in the instance state.
Although it's perfectly valid to store every agent in the instance state, this usually isn't necessary. Most games have a lot of data that is rarely, or never, modified. Rather than storing this data in every GameInstance
, it's more efficient to store these agents in the game's static context. Remember, static data is defined outside the game cycle and is separate from the instance state.
Static agents are agents defined outside of the game cycle.
Once activated, static agents can be used inside events just like non-static agents.
// Declare a new agent class called Book
class Book extends Agent {
constructor(
public title: string,
public author: string,
public content: string
) {
super();
}
}
// Declare a static Book agent
const NOVEL = new Book(
"Frankenstein",
"Mary Shelley",
/* really long string */
);
const read = on("READ", game => {
const novel = game.using(NOVEL); // Activate the static agent
game.output.write(`You open ${novel.title}, by ${novel.author}.`);
const excerpt = novel.content.split(" ").slice(0, 4).join(" "); // Grab the first 4 words
game.output.write(`It begins, "${excerpt}..."`);
});
Note: This example is available here.
Executing read
on a GameInstance
would produce the following output:
READ: You open Frankenstein, by Mary Shelley.
READ: It begins, "To Mrs. Saville, England..."
Unlike non-static agents, static agents may have their properties read or modified, but only outside of the game cycle. For example, this would be okay:
const NOVEL = new Book(
"Frankenstein",
"Mary Shelley",
/* really long string */
);
NOVEL.title += ", or The Modern Prometheus"; // No error!
In order to use a static agent's properties within a game cycle, however, it must be activated.
This event modifies several properties of the NOVEL
static agent:
const revise = (playerName: string, forward: string) =>
on("REVISE", game => {
const novel = game.using(NOVEL);
novel.content = forward + " " + novel.content;
novel.author += ` (with a forward by ${playerName})`
});
Executing the queue revise("Lars", "Pancakes!").then(read)
on a GameInstance
would produce the following output:
READ: You open Frankenstein, by Mary Shelley (with a forward by Lars).
READ: It begins, "Pancakes! To Mrs. Saville,..."
Both events, read
and revise
, activated NOVEL
independently of each other, yet the changes made in revise
were still there in read
. This is because the changes made to static agents are stored in the instance state. The static agent's properties that weren't changed (in this case, just the author) don't need to be stored in the state.
Static agents save space by storing only the changes made to their properties by a specific game instance in that instance's state.
If two different game instances reference the same static agent, their changes will not affect each other. This is because changes made to a static agent don't actually modify the static agent at all; they are simply stored in the instance state.
Randomness
Randomness is an essential part of many games, so the Regal Game Library provides a convenient way to generate random values through the GameInstance
.
Generating Random Values
GameInstance.random
contains an object of type InstanceRandom
, which is an interface for generating random values.
InstanceRandom
has methods for generating random integers, decimals, strings, and booleans.
const randos = on("RANDOS", game => {
const bool = game.random.boolean(); // Either true or false
game.output.write(`Boolean -> ${bool}`);
const int = game.random.int(1, 10); // Integer between 1 and 10, inclusive
game.output.write(`Int -> ${int}`);
const dec = game.random.decimal(); // Random decimal betweeen 0 and 1
game.output.write(`Decimal -> ${dec}`);
const str = game.random.string(10); // Random string of length 10
game.output.write(`String -> ${str}`);
});
Note: This example is available here.
Executing randos
on a GameInstance
would produce values like the following:
RANDOS: Boolean -> true
RANDOS: Int -> 5
RANDOS: Decimal -> 0.38769409744713784
RANDOS: String -> qj$4d-28DX
InstanceRandom.string()
has an optional charset
property that can be used to specify the characters that are chosen from when the string is generated.
For example, if charset
is "aeiou"
, then the random string will only contain vowels.
Charsets
is a static object that contains several common charset strings for this purpose.
import { Charsets } from "regal";
const rstrings = on("RSTRINGS", game => {
const alphanumeric = game.random.string(10, Charsets.ALHPANUMERIC_CHARSET);
game.output.write(`Alphanumeric -> ${alphanumeric}`);
const alphabet = game.random.string(10, Charsets.ALPHABET_CHARSET);
game.output.write(`Alphabet -> ${alphabet}`);
const numbers = game.random.string(10, Charsets.NUMBERS_CHARSET);
game.output.write(`Numbers -> ${numbers}`);
const hex = game.random.string(10, Charsets.NUMBERS_CHARSET + "ABCDEF");
game.output.write(`Hex -> ${hex}`);
const binary = game.random.string(10, "10");
game.output.write(`Binary -> ${binary}`);
game.output.write(`Old MacDonald had a farm, ${game.random.string(5, "eio")}.`);
});
Executing rstrings
on a GameInstance
would produce values like the following:
RSTRINGS: Alphanumeric -> AEeLn85uLT
RSTRINGS: Alphabet -> RoGfYDtwcL
RSTRINGS: Numbers -> 2132069253
RSTRINGS: Hex -> 69072CF9B5
RSTRINGS: Binary -> 1111011001
RSTRINGS: Old MacDonald had a farm, oeioe.
InstanceRandom.choice()
chooses a random element from an array without modifying anything. This works with arrays of primitives or agents.
class Item extends Agent {
constructor(public name: string) {
super();
}
}
const init = on("INIT", game => {
game.state.items = [
new Item("Yo-Yo"),
new Item("Pigeon"),
new Item("Corn cob")
];
});
const rpick = on("RPICK", game => {
const i: Item = game.random.choice(game.state.items);
game.output.write(`You picked the ${i.name}!`);
});
Executing init.then(rpick, rpick, rpick)
on a GameInstance
would produce values like the following:
RPICK: You picked the Pigeon!
RPICK: You picked the Yo-Yo!
RPICK: You picked the Pigeon!
Deterministic Randomness
Regal games are by definition deterministic, meaning that they always return the same output when given the same input. The methods for generating random values described above might seem to disobey this principle, but they do not.
When a GameInstance
is created, it is given a special value called a seed. This seed value initializes the game instance's internal random number generator and has a direct influence on all random values that come out of it.
The seed value may be set manually as a configuration option. If no seed is set, one will be generated randomly at runtime.
A game instance's seed is considered part of its input. Therefore, it plays a factor in determining the game's output. If two game instances have the same seed, they will generate the exact same sequence of random values. If a GameInstance
is reset with an undo command, then its random value stream will be reset as well.
In order for Regal to work properly, all random values should be generated by InstanceRandom. JavaScript's Math.Random()
or other libraries for generating random values are not recommended.
Output
Up to this point, you've probably noticed the other examples calling game.output.write()
to send messages to the game's output. write()
is one of several methods provided by InstanceOutput
, the interface for controlling and emitting output through the GameInstance
.
InstanceOutput
is accessible through GameInstance.output
.
Output is handled line-by-line. An OutputLine
is modeled as a text string (data
) with a property that specifies its semantic meaning (type
). These types, which are stored as an enum called OutputLineType
, include NORMAL
, MAJOR
, MINOR
, DEBUG
, and SECTION_TITLE
.
InstanceOutput
contains a method for emitting each of these types of output.
const out = on("OUT", game => {
game.output.writeNormal("This is normal output. Most of your game's output will be this type.");
game.output.write("InstanceOutput.write is just a shorthand for writeNormal!");
game.output.writeMajor("This is major output. Save this for really important stuff.");
game.output.writeMinor("This is minor output. Use this for repetitive/flavor text that isn't necessary for the player to see.");
game.output.writeDebug("This is debug output. It's only visible when the debug option is enabled.")
game.output.writeTitle("This is a title.");
});
Note: This example is available here.
Executing the out
event on a GameInstance
would produce the following output:
OUT: This is normal output. Most of your game's output will be this type.
OUT: InstanceOutput.write is just a shorthand for writeNormal!
OUT: This is major output. Save this for really important stuff.
OUT: This is minor output. Use this for repetitive/flavor text that isn't necessary for the player to see.
OUT: This is a title.
Notice that the line of debug output didn't show up. That's because output lines of type DEBUG
are only emitted when the debug
option is set to true, and it is set to false by default. Similarly, output lines of type MINOR
are only emitted when the showMinor
option is set to true, which is its default value.
Executing the event again with debug: true
and showMinor: false
in the game configuration produces the following output:
OUT: This is normal output. Most of your game's output will be this type.
OUT: InstanceOutput.write is just a shorthand for writeNormal!
OUT: This is major output. Save this for really important stuff.
OUT: This is debug output. It's only visible when the debug option is enabled.
OUT: This is a title.
Despite each of these lines having different types, they all look the same when printed via the default Regal client. This is because OutputLine.type
is left to the client for interpretation; it's up to the Regal client to decide how each type of output is handled. For more information, see Handling Responses.
GameApi
and API Hooks
Regal games are played through a GameApi
, which is the public interface for interacting with a Regal game. A client application will consume this API, using its methods to generate game instances, post commands, and receive output.
GameApi
has the following signature:
interface GameApi {
getMetadataCommand(): GameResponse
postPlayerCommand(instance: GameInstance, command: string): GameResponse
postStartCommand(options?: Partial<GameOptions>): GameResponse
postUndoCommand(instance: GameInstance): GameResponse
postOptionCommand(instance: GameInstance, options: Partial<GameOptions>): GameResponse
}
Each method is a different type of command that can be sent to the Regal game and returns some GameResponse
.
Three of these commands, postPlayer
, postUndo
, and postOption
, all require the current GameInstance
. The other two, getMetadata
and postStart
, do not.
Some of these commands will be described in more detail below. Consult the API Reference for a complete description.
Handling Commands with API Hooks
Out of the five game commands listed above, two of them must be handled explicitly by the game developer. These commands, postStart
and postPlayer
, are handled by the hook functions onStartCommand
and onPlayerCommand
respectively.
A hook function is used to control what happens when a command is received by the GameApi
.
onStartCommand
takes an EventFunction
as its only argument. This event function is executed when a postStart
command is sent to the GameApi
.
import { onStartCommand } from "regal";
onStartCommand(game => {
game.output.write("Hello, world!");
});
With this hook in place, starting a new game will return the following output to the player:
START: Hello, world!
onPlayerCommand
is slightly more complicated. It takes a function which receives a string as input and outputs an EventFunction
. This string will be any text command sent by the player and the event will be executed when a postPlayer
command is sent to the GameApi
.
import { onPlayerCommand } from "regal";
onPlayerCommand(command => game => {
game.output.write(`You wrote '${command}'!`);
});
With this hook in place, a player's input (like dance
) will return the following output:
INPUT: You wrote 'dance'!
Both onStartCommand
and onPlayerCommand
must be called exactly once from somewhere in your game's source.
Using Game
The Regal Game Library's global implementation of the extended game API is called Game
. The Game
object is used for external interaction with the game and shouldn't be accessed within the game itself.
Usually, you won't use Game
directly. Regal games should be bundled before they are used by clients, and these bundles have a different way of exposing a GameApi
. See bundling for more information.
However, there are certain cases where you might prefer to access Game
directly, such as for unit tests. In these situations, both Game
and your game's source need to be imported.
import { Game } from "regal";
import "./my-game-src"; // Imports the game's root file, which has no exports
Before any command may be executed, Game.init()
must be called first. This method takes a GameMetadata
object, which must contain at least a name
and an author
.
Game.init({
name: "My Cool Game",
author: "Me"
});
Using the hooks defined earlier, a new game instance can be generated with Game.postStartCommand()
:
const startResponse = Game.postStartCommand();
The resulting GameResponse
has two properties: instance
and output
. instance
refers to the current GameInstance
. output
contains all output generated by the last game cycle, which here would be:
{
"log": [
{
"data": "Hello, world!",
"id": 1,
"type": "NORMAL"
}
],
"wasSuccessful": true
}
Similarly, a player command can be executed with Game.postPlayerCommand()
. This method takes two arguments: instance
and command
. instance
refers to the current GameInstance
, and command
is a string containing the player's command.
const playerResponse = Game.postPlayerCommand(startResponse.instance, "bark");
This response contains a new GameInstance
, leaving the original instance unchanged, and the following output:
{
"log": [
{
"data": "You wrote 'bark'!",
"id": 2,
"type": "NORMAL"
}
],
"wasSuccessful": true
}
When a client plays a Regal game, all it has to do behind the scenes is send a series of these commands.
Note: This example is available here.
Undoing Player Commands
Because Regal games are deterministic, the effects of any command on the GameInstance
can be undone. Essentially, this "rolls back" the instance state to the state it was at before the reverted command was executed.
For example, here is a game that stores each command in an array in the state:
// undo-game-src.ts
import { onPlayerCommand, onStartCommand, on } from "regal";
// Print out the list of items in the state
const printItems = on("ITEMS", game => {
const itemsString = game.state.items.join(", ");
game.output.write(`Items (${game.state.items.length}) -> [${itemsString}]`);
});
onStartCommand(game => {
game.state.items = []; // Initialize state.items with an empty array
return printItems;
});
onPlayerCommand(command => game => {
game.output.write(`Adding ${command}.`);
game.state.items.push(command); // Add the new item to the state
return printItems;
});
Note: This example is available here.
Executing a start command would produce the following output:
ITEMS: Items (0) -> []
Executing three player commands, cat
, dog
, and mouse
, would result in this output:
INPUT: Adding cat.
ITEMS: Items (1) -> [cat]
INPUT: Adding dog.
ITEMS: Items (2) -> [cat, dog]
INPUT: Adding mouse.
ITEMS: Items (3) -> [cat, dog, mouse]
A client can undo the last player or undo command executed on the GameInstance
with Game.postUndoCommand()
. This method takes only one argument, which is the current GameInstance
.
If the following line was executed:
const newInstance = Game.postUndoCommand(oldInstance);
Then newInstance
would contain the state of the instance from before the player's mouse
command was executed. oldInstance
would remain unchanged.
If another player command, goose
, was executed, then this would be the output:
Adding goose.
Items (3) -> [cat, dog, goose]
While being able to undo commands can be quite useful, it doesn't make sense for all games. Therefore, the game developer can control whether the GameApi
should allow clients to execute undo commands. This is accomplished with an API hook function, onBeforeUndoCommand
.
Like the other hook functions, onBeforeUndoCommand
controls the behavior of the Game API. Its only argument is a function that receives a GameInstance
and returns a boolean.
Whenever a postUndo
command is sent to the GameApi
, the beforeUndo
hook is executed on the current GameInstance
. If the function returns true, the undo will proceed. If it returns false, an error will throw instead.
To prevent all undo commands, simply make the beforeUndo
hook always return false:
import { onBeforeUndoCommand } from "regal";
onBeforeUndoCommand(game => false);
With this configuration, executing a postUndo
command would return an unsuccessful response with the following error:
RegalError: Undo is not allowed here.
onBeforeUndoCommand
can be used to allow or deny undo commands based on some condition of the game state. For instance, the following hook prevents our example from being undone when a previous player command was goose
:
onBeforeUndoCommand(game =>
!game.state.items.includes("goose") // Block undo if there's a goose in the array
);
A call to onBeforeUndoCommand
is not required, and will default to always allowing undo commands if not otherwise set.
Configuration
A Regal game's configuration consists of two types: GameMetadata
and GameOptions
.
GameMetadata
contains metadata about the game, such as its title and author. GameOptions
contains configurable options to control the game's behavior.
Using Configuration Files
Configuration for a Regal game is usually kept in the project's root directory, in a file called regal.json
. This file contains both metadata and options.
A game's regal.json
file might look something like this:
{
"game": {
"name": "My Awesome Game",
"author": "Joe Cowman",
"headline": "This is the best game ever.",
"description": "Let me tell you why this is the best game ever...",
"options": {
"debug": true,
"seed": "1234"
}
}
}
Note that a regal.json
file is optional. The essential properties of GameMetadata
, which are name
and author
, as well as others properties will be taken from those of the same keys in package.json
if they aren't specified in a regal.json
file.
A configuration loading tool like regal-bundler is needed if using regal.json
or the regal
property in package.json
. See bundling for more information.
Alternatively, metadata values can be passed explicitly via GameApiExtended.init()
. Either way, a metadata object with at least the name
and author
properties specified is required before a game can receive commands.
import { Game } from "regal";
Game.init({
name: "My Game",
author: "Me"
});
Configuring Game Options
The Regal Game Library provides several options for configuring the behavior of game instances. These options are stored as an interface called GameOptions
.
When a game is initialized, either through GameApiExtended.init()
or loading a regal.json
file, any values provided in GameMetadata.options
will become the default values for every instance of that game.
Here is an example:
// options-demo.ts
import { Game } from "regal";
import "./options-game-src";
Game.init({
name: "My Game",
author: "Me",
options: {
debug: true, // Set debug to true
seed: "Hello!" // Set seed to "Hello!"
}
});
With this configuration, every instance of My Game
would start with its debug
option set to true instead of false, and its seed
set to "Hello!".
A convenient way to check the values of an instance's GameOptions
is by using GameInstance.options
, which is a read-only container for all current options in the instance. The property is of type InstanceOptions
.
This can be combined with InstanceOutput.writeDebug()
to produce some helpful information for development:
// options-game-src.ts
onStartCommand(game => {
game.output.write("Startup successful.");
game.output.writeDebug(`Current seed -> ${game.options.seed}`);
});
Starting a new game instance using the configuration from earlier would yield the following output:
START: Startup successful.
START: Current seed -> Hello!
If you wanted to generate a GameInstance
with debug
disabled, you could pass in any option overrides to the optional argument of Game.postStartCommand()
.
// options-demo.ts
let res = Game.postStartCommand({ debug: false });
With debug
set to false, res
would contain only the one line of output:
START: Startup successful.
To modify the options of a pre-existing GameInstance
, use Game.postOptionCommand()
.
res = Game.postOptionCommand(res.instance, { showMinor: false });
This would disable showMinor
for all future game cycles of the GameInstance
(unless it got changed again).
Note: This example is available here.
The priority of GameOptions
from highest to lowest is:
- Instance-specific overrides (i.e. values passed in
Game.postStartCommand()
orGame.postOptionCommand()
). - Static overrides (i.e. values loaded from
regal.json
or set withGame.init()
). - The options' default values.
Bundling
In order for a Regal game to be played by clients, it must be bundled first. Bundling is the process of converting a Regal game's development source (i.e. the TypeScript source files that the game developer writes) into a game bundle, which is a self-contained file that contains all the code necessary to play the game via a single API.
Game bundles are the form through which Regal games are shared, downloaded, and played.
Using the CLI
The easiest way to bundle a game is with the Regal CLI.
Once you have the CLI installed, use the bundle
command to generate a game bundle.
$ regal bundle
Created a game bundle for 'my-first-game' at /Users/user/myDir/my-first-game.regal.js
This creates a JavaScript file, which contains all the game's dependencies (including the Game Library) and exports an implementation of GameApi
for playing the game. By default, the generated bundle file will be named my-game.regal.js
, where my-game
is a sanitized version of the game's name, as specified in GameMetadata
.
For a list of configuration options you can use, consult the CLI's documentation.
Using regal-bundler
You can access the bundling API directly with regal-bundler.
First, install regal-bundler
:
npm install --save-dev regal-bundler
Generate a game bundle with the asynchronous bundle()
function.
import { bundle } from "regal-bundler";
bundle(); // Creates a bundle file
bundle()
accepts an optional configuration object that can be used to control certain behaviors of the bundler, such as the location of input
or output
files, the module format
of the bundle, or whether minification s