Introduction¶
Life sims all seem to have two problems in common:
Too much world state¶
The number of variables the game is tracking – just for game logic, not graphics or physics – is very large. The Sims, for example, tracks sims’ opinions of one another, their likes and dislikes and so forth, even for the sims you never talk to and have shown no interest in. If you streamline a life sim to where it doesn’t have extraneous detail, you lose a huge part of what makes it lifelike. This causes trouble for developers when even they don’t understand why sims hate each other.
To address all those problems, Lisien provides a persistent state container. Everything that ever happens in a Lisien game is recorded, so that you can pick through the whole history and find out exactly when a butterfly flapped its wings to cause a cyclone. Time travel is aggressively optimized, so that the experience of browsing a playthrough’s history is as smooth as browsing a video. All of that history gets saved in a database, which is used in place of traditional save files. If your testers discover something strange, and want you to know about it, they can send you their database, and you’ll know everything they did, and everything that happened, in their game.
Too many rules¶
Fans of life sims appreciate complexity. Developers want to reduce complexity as much as possible. So Lisien makes it easy to compartmentalize complexity, and choose what of it you want to deal with, and when.
It is a rules engine, an old concept from business software that lets you configure what conditions cause what effects. Although you’ll still need to write some Python code for the conditions and the effects that they have, the connections between the conditions and the effects, along with the set of objects that follow that rule, are defined in data. Once you’ve written a short Python function that works in the rules engine, it’s easy to reuse it for another rule on another object. Changing the rules while the game is running is just as easy, so you can experiment with ideas for rules as soon as you have them.
Concepts¶
Lisien is a tool for constructing turn-based simulations following rules in a directed graph-based world model.
Rules are things the game should do in certain conditions.
In Lisien, the “things to do” are called “actions.”
These are functions that take an entity in the world,
and change it – or possibly anything else in the world – as you see fit.
The conditions are divided into “triggers” and “prereqs,”
of which only triggers are truly necessary:
they are Boolean functions, taking the same entity,
one of which must return True for the action to proceed.
A directed graph is made of nodes and edges. The nodes are points without fixed locations. Edges in a directed graph connect one node to another node, but not vice-versa, so you can have nodes A and B where A is connected to B, but B is not connected to A. Edges are usually drawn as arrows. There can be edges going in both directions between A and B.
In Lisien, edges are called Portals,
and nodes may be Places or Things.
You can use these to represent whatever you want,
but they have special properties to make it easier to model physical space:
in particular, each Thing is located in exactly one node at a time
(usually a Place).
Regardless, you can keep any data you like in a Thing,
Place, or Portal by treating it like a dictionary.
Lisien’s directed graphs are called Characters.
Every time something about a Character changes,
Lisien remembers when it happened – that is,
which turn of the simulation, and which tick within the turn.
This allows the developer to look up the state of the world at some point in the past.
This time travel is nearly real-time in most cases,
to make it convenient to flip back and forth
between a correct world state and an incorrect one
and use your intuition to spot exactly what went wrong.
See Design for details.
Usage¶
The only Lisien class that you should ever instantiate yourself is Engine.
All simulation objects should be created and accessed through it.
By default, it keeps the simulation code and world state in the working directory,
but you can pass in another directory if you prefer.
Either use it with a context manager (with Engine() as eng:)
or call its close() method when you’re done.
World Modelling¶
Start by calling the Engine’s new_character() method
with a name to get a Character object.
Draw a graph in the Character by calling its method
new_place() with many different names to get some Places,
then linking them together with their method new_portal().
To store data pertaining to some specific place,
retrieve the place from the place mapping of the character:
if the character is world and the place name is 'home',
you might do it like home = world.place['home'].
Portals are retrieved from the portal mapping,
where you’ll need the origin and the destination:
if there’s a portal from 'home' to 'narnia',
you can get it like wardrobe = world.portal['home']['narnia'],
but if you haven’t also made another portal going the other way,
world.portal['narnia']['home'] will raise KeyError.
Things, usually being located in Places
(but possibly in other Things),
are most conveniently created by the new_thing() method of node objects
(shared by Place and Thing):
dorothy = home.new_thing('dorothy') gets you a new Thing object located in home.
Things can be retrieved like dorothy = world.thing['dorothy'].
Ultimately, Things and Places are both just nodes,
and both can be retrieved in a character.Character’s node mapping,
but only Things have methods like travel_to(),
which finds a path to a destination and schedules movement along it.
You can store data in Things, node.Places, and Portals
by treating them like dictionaries.
If you want to store data in a Character,
use its stat property as a dictionary instead.
Data stored in these objects, and in the universal property of the engine,
can vary over time, and be rewound by setting turn to some time before.
The Engine’s eternal property is not time-sensitive,
and is mainly for storing settings,
not simulation data.
Rule Creation¶
To create a Rule, first decide what objects the Rule should apply to.
You can put a Rule on a
Character,
Thing,
Place, or
Portal;
and you can put a rule on a Character’s
thing,
place, and
portal mappings,
meaning the Rule will be applied to every such entity within the Character,
even if it didn’t exist when the rule was declared.
All these items have a property rule
that can be used as a decorator.
Use this to decorate a function that performs the Rule’s action
by making some change to the world state.
The function should take only one argument, the item itself.
At first, the Rule object will not have any triggers,
meaning the action will never happen.
If you want it to run on every tick,
pass the decorator always=True and think no more of it.
But if you want to be more selective,
use the rule’s trigger decorator on another function
with the same signature, and have it return True if the world is in
such a state that the rule ought to run. Triggers must never mutate the
world or use any randomness.
If you like, you can also add prerequisites.
These are like triggers, but use the prereq decorator,
and should return True unless the action should not happen;
if a single prerequisite returns False, the action is cancelled.
Prereqs may involve random elements.
Use the engine property of any Lisien entity to get the Engine,
then use methods such as percent_chance() and dice_check().
A prerequisite may return a value that is not True or False.
This indicates that the game should stop and ask the user for input.
If you’re using Elide, the appropriate return type is described in elide.screen.DialogLayout.todo.
Time Control¶
The current time is always accessible from the Engine’s time property.
In the common case where time is advancing forward one tick at a time,
it should be done with the Engine’s next_turn method,
which polls all the game rules before going to the next turn;
but you can also change the time whenever you want,
as long as branch is a string and turn is an integer.
The rules will never be followed in response to your changing the time “by hand”.
It is possible to change the time as part of the action of a rule.
This is how you would make something happen after a delay.
Say you want a rule that puts the Character alice to sleep,
then wakes her up after eight turns (presumably hour-long):
alice = engine.character['alice']
@alice.rule
def sleep(character):
character.stat['awake'] = False
start_turn = character.engine.turn
with character.engine.plan() as plan_num:
character.engine.turn += 8
character.stat['awake'] = True
character.stat['wake_plan'] = plan_num
At the end of a Engine.plan() block,
the game-time will be reset to its position at the start of that block.
You can use the plan’s ID number, plan_num in the above,
to cancel it yourself – some other rule could call
engine.delete_plan(engine.character['alice'].stat['wake_plan']).
Input Prompts¶
Lisien itself doesn’t know what a player is or how to accept input from them, but does use some conventions for communicating with a user interface such as Elide.
To ask the player to make a decision, first define a method for them to call, then return a menu description like this one:
@engine.method
def wake_alice(self):
self.character['alice'].stat['awake'] = True
alice = engine.character['alice']
@alice.rule
def wakeup(character):
return "Wake up?", [
("Yes", character.engine.wake_alice),
("No", None)
]
Only methods defined with the @method function store may be used in a menu.
In Elide, that means you have to define them in the Method tab of the Python Editor.
Proxies¶
Lisien may be run in a separate process from Elide, or any other frontend you may write for it. To ease the process of writing such frontends in Python, Lisien provides proxy objects that reflect and control their corresponding objects in the Lisien core.
Use EngineProxyManager to start Lisien in a subprocess and get a
proxy to the engine:
from lisien.proxy import EngineProxyManager
manager = EngineProxyManager('gamedir/')
engine_proxy = manager.start(workers=4)
# do stuff here
manager.shutdown()
You can pass Engine arguments to the start() method.
The proxy objects are mostly the same as what they represent, with affordances for when you
have to do some work in the user interface while waiting for the core to finish something.
Generally, you can pass a callback function to the relevant object’s connect() method,
and Lisien will call the callback at the relevant time.
Here’s how you’d run some code whenever next_turn finishes running the rules engine:
from threading import Thread
from lisien.proxy import EngineProxyManager
from my_excellent_game import display_menu, apply_delta
manager = EngineProxyManager()
with manager.start() as engine_proxy:
@engine_proxy.next_turn.connect
def update_from_next_turn(engine, menu_info, delta):
display_menu(*menu_info)
apply_delta(delta)
subthread = Thread(target=engine_proxy.next_turn)
subthread.start()
# do some UI work here
subthread.join()