Polar Adventure: A text-based adventure game written in Polar
We’re building oso, an open source policy engine for authorization. We want to make it easy to write policies on your existing data, so we created a new language called Polar. Polar was designed for writing authorization code, and while it’s great for that, it’s also a general purpose configuration language that can be used for a lot more. Recently during a company hackathon we decided to try out using Polar for a different kind of use case – making a game! You can find the source code for this project here.
What is Polar?
Before we get into the game, here's some quick background on the Polar language. In Polar, you write a policy of rules that can later be queried. You can define a rule like this:
allow(user, "access", "computer") if
user.is_admin or user.name = "steve";
Then you can query the rule:
> allow({name: "steve", is_admin: false}, "access", "computer")
=> True
allow(anon_user, "access", "computer")
=> False
The Polar language is heavily inspired by Prolog and has similar semantics. For instance, all results of a query are returned, not just the first:
f(1);
f(2);
f(3);
> f(x)
=> x = 1
x = 2
x = 3
But Polar differs from Prolog in a significant way. Polar is an embedded language, which means it’s called from a different host language like Python or Java. You can pass in native objects from the host language and access them directly:
class User:
def __init__(self, name):
self.name = name
steve = User("steve")
oso.query_rule("allow", steve, "access", "computer")
=> True
In addition to accessing the fields and methods of application objects, you can actually write Polar rules that specialize on host language classes. Adding a specializer to a rule argument means that the rule will only be evaluated if the argument matches the specializer. Specializers automatically respect the inheritance hierarchy of the application classes.
# python file
class Admin(User):
pass
# polar file
allow(_actor: User, "access", "admin_page") if false;
allow(_actor: Admin, "access", "admin_page") if true;
In the above example, if you pass an instance of Admin to an allow query, both rules will be evaluated, because an instance of Admin matches both Admin and User. If you pass in a User object, only the rule that specializes on User will be matched. Specializers let you take advantage of Polar's multiple dispatch (or multi-methods) feature, which we did when building the Polar Adventure game. For a more thorough introduction to Polar, read the documentation.
Polar Adventure!
We built these features into Polar to make it easy to write authorization logic, but they apply to a lot of other problems too—which brings us to our game! The game is a classic text adventure. The player can take actions and interact with the world via different commands. The command-response nature of the game matches the query-response nature of Polar, so we made the whole game playable from the Polar REPL. The actions the player can take – things like go("iron gate") or look("envelope") – are just regular Polar queries.
Stop here
We're going to get into some of the details of the game now which includes major spoilers. The game isn't just educational, it's also fun so we highly recommend you go to https://repl.it/github/osohq/polar-adventure and try it out before reading on.
How does it work?
There are two sides to the game. The data, which is stored in Python and made available to Polar via a couple of helper classes, and the Polar code, which has all the logic for the game. We created a base class for our game objects called Object, which has an id and a desc (short for descriptor). We also have other "attribute classes" such as Animal and Food, which optionally have other fields. Every object in the game is of a class that subclasses Object and optionally subclasses some of these attribute classes. Structuring the classes this way let us write Polar rules over the objects using specializers.
As mentioned above, specialization is a feature that lets us specialize a rule on the types of the arguments. For example.
f(x) if true;
f(x: String) if x = "foo";
f(x: Object) if x.id = 1;
Each of these rules has the same rule name and same argument name, but a different specializer. The policy engine checks that the argument matches the specializer before evaluating the rule.
> f(1)
=> True
> f("foo")
==> True
True
Calling with an integer only matches the specializer for the first rule, so only that rule is evaluated. Calling with a string matches both the first and the second rules, so they both are evaluated. Specializer rules are called in order from most specialized to least specialized. In this case, the second rule is called before the first one. Rule ordering is an important effect of specializers that we took full advantage of when building the game.
Rules
The commands in the game are queries like look("map") or use("carrot", "cat"), which take object names as arguments. Most of these rules are wrappers that call Python methods to get the objects by name from the in-memory database, then pass the objects into other Polar rules. For each command, we have defined multiple rules that specialize on the argument type (sometimes on multiple arguments). The Polar runtime uses multi-method dispatch to evaluate the appropriate rules based on the specializers. Below we've detailed a few examples to show how this works.
Object Detail
A simple example is look(obj), a complicated rule that calls many other rules. One of the rules it calls is _object_detail(obj), which prints a detailed description of the provided object. We have defined many different _object_detail rules in the world.polar policy.
_object_detail(obj: Object) if
GAME.write(" The {} isn't very interesting to look at.\n", GAME.blue(obj.desc));
This is an _object_detail rule that takes an Object. It will match any instance of Object (our root class), and thus ends up being the default rule.
_object_detail(obj: Object{desc: "dog"}) if
GAME.write(" A real sleepy pup. Their collar says REX\n", GAME.blue(obj.desc)) and cut;
This rule matches an Object whose desc field is “dog”. This rule is more specific than the rule that matches any Object, so it will get called first. At the end of the rule we have a cut operator, which stops ends the query evaluation, preventing the default rule from being called. The result is that when we look at the "dog", we see the more specific message and not the default one.
Our implementation of _object_detail for containers provides a more complicated example of multi-methods:
_object_detail(container: Container) if _container_objects(container) and cut;
_container_objects(container: Container{is_open: false}) if
GAME.write(" You can't see into the {}.\n", GAME.blue(container.desc)) and cut;
_container_objects(container: Container{is_open: true}) if
GAME.write(" The {} contains: ", GAME.blue(container.desc)) and
forall(obj_id in container.objects,
object = Objects.get_by_id(obj_id) and
GAME.write("\n a {}", GAME.blue(object.desc))) and
GAME.write("\n") and cut;
_container_objects(container: Container{is_open: true, objects: []}) if
GAME.write(" The {} is empty.\n", GAME.blue(container.desc)) and cut;
The _object_detail rule matches any Container. It calls _container_objects(container), which has three different definitions. The first matches if the container is closed, the second matches if the container is open, and the third matches if the container is open and the list of objects is empty. The last rule is more specialized than the previous one that only checks if the container is open, so if the container is empty it matches first and displays the correct message.
Object Extras
Most of the _object_detail rules have a cut operator at the end so the other rules that match don’t get called. In other cases we would like all the rules that match to run. We have a rule called _object_extras which is called after _object_detail (as part of look) to print some extra information about the object. We have a number of rules based on the different "attribute classes" that game objects can inherit from.
_object_extras(obj: Mushroomy) if
GAME.write(" The {} has little mushrooms growing out of it.\n", GAME.blue(obj.desc));
_object_extras(obj: Wet) if
GAME.write(" The {} is soaking wet.\n", GAME.blue(obj.desc));
_object_extras(obj: OnFire) if
GAME.write(" The {} is on fire.\n", GAME.blue(obj.desc));
_object_extras(obj: Leafy) if
GAME.write(" The {} has little leaves growing out of it.\n", GAME.blue(obj.desc));
In this case we’re not cutting, so if we look at an object that inherits from Mushroomy, Wet, OnFire and Leafy, all four messages will be printed.
Use
Specializers work on all arguments (not just the first one). When you call use(obj1, obj2), we call a rule _use(obj1, obj2). There are many different definitions of _use that are defined for different object combinations.
For example, when you use the "bag of mushroom spores" on an object, the Mushroomy class is added as a superclass of that object:
_use(_: Object{desc: "bag of mushroom spores"}, obj: Object) if
Objects.add_class(obj.id, "Mushroomy") and
GAME.write(" you sprinkle mushroom spores on {}.\n", GAME.blue(obj.desc));
You can feed food to an animal (this uses a different command, feed, but works the same way as use).
_action_object("feed", room: Room, food: Food, animal: Animal) if
(
(food.id in room.objects and room.remove_object(food.id)) or
(food.id in PLAYER.objects and PLAYER.remove_object(food.id))
) and (
(_feed_soup_to_dog(room, food, animal) and cut) or
GAME.write(" the {} ate the {}\n", GAME.blue(animal.desc), GAME.blue(food.desc))
);
Specializer patterns can also bind variables so you can do some pattern matching across objects with just specializers. If you use an item that is an animal's favorite item on that animal, it makes them happy:
_use(_: Object{desc: fav_item}, animal: Animal{favorite_item: fav_item}) if
GAME.write(" {} smiles, they love the {}\n", GAME.blue(animal.desc), GAME.blue(fav_item));
Summary
These were just a few examples of how Polar was useful to us in building Polar Adventure. While we normally think about the language through the lens of authorization, it can be great for many other use cases that involve declarative logic, decision-making, or searching. By using host language data directly, you can write Polar code over the app and data model you already have without any strange translation between the application and prolog data structures.
Writing this game was really fun and we're excited to continue pushing Polar in different directions to see what other problems it can help simplify.
To share your thoughts on Polar Adventure, ask technical questions or give feedback, join us on Slack or open an issue.