This tutorial will guide you through the process of creating a new substrate. By the end of this tutorial, you will have a fully functioning game, a binary to allow you to play it interactively, and minimal tests in Lua and Python.
By the end, your game will look like this:
The code for this tutorial is in meltingpot/examples/tutorial/harvest
, and you
can try it out like this:
$ # Run the empty game
$ python examples/tutorial/harvest/play_harvest.py --observation WORLD.RGB --display_text
You should see a blank window called Melting Pot: harvest
with the text This page intentionally left blank
like in the above image.
If you get an error like:
UnboundLocalError: local variable 'game_display' referenced before assignment
make sure you are including the -- --observation WORLD.RGB
parameter in your
command. The reason for this is that the empty substrate only has two
observations: WORLD.RGB
and WORLD.TEXT
. We will add more later on, including
the default per-player RGB
one.
If you just want to skip ahead and look at the finished game, simply change the
import in
play_harvest.py
from
from .configs.environment import harvest as game
to
from .configs.environment import harvest_finished as game
and launch as desired:
python examples/tutorial/harvest/play_harvest.py
for first person view; or
python examples/tutorial/harvest/play_harvest.py --observation WORLD.RGB
for third person view.
Ok, we can launch an empty game, but that is certainly not very interesting. Let us, at least, have an avatar that we can move around.
The first thing to do is to set our number of players to 1 and add the
individual observation to the config in
harvest.py
:
config.num_players = 1
config.individual_observation_names = ["RGB"]
Everything in our substrate, including avatars, is a GameObject
. You can think
of GameObject
s as dumb containers of functionality. Functionality is
provided via Component
s. You can learn details of how components work in the
concepts page. For the purposes of this tutorial, just think of
GameObject
s as the elements of your substrate, and Component
s as modular
game logic you add to them.
To instantiate the player in the game, we need a GameObject
with the avatar
component. This is contained in
avatar_library.lua
.
Defining a game object is done in the python substrate configuration, just like
we set the config.num_players
above. Let's define our avatar GameObject
by
adding the following as a global:
AVATAR = {
"name": "avatar",
"components": [
{
"component": "StateManager",
"kwargs": {
"initialState": "player",
"stateConfigs": [
{"state": "player",
"layer": "upperPhysical",
"sprite": "Avatar",},
]
}
},
{
"component": "Transform",
},
{
"component": "Appearance",
"kwargs": {
"spriteNames": ["Avatar"],
"spriteRGBColors": [(0, 0, 128)],
"palettes": [{}], # Will be overridden.
}
},
{
"component": "Avatar",
"kwargs": {
"aliveState": "player",
"waitState": "playerWait",
"spawnGroup": "spawnPoints",
"view": {
"left": 3,
"right": 3,
"forward": 5,
"backward": 1,
"centered": False,
}
}
},
]
}
Let's look a bit into what is going on here. The AVATAR
is what we call a
prefab: a template from which actual GameObject
s will be built. This is not
an actual GameObject
, but think of this as the configuration needed to make
one.
Prefabs have the following structure:
{
"name": "some_name", # The name of the prefab. An arbitrary string.
"components": [ # A list of components for the GameObject.
# You can add as many components as you want. Even of the same type.
]
}
If you want to learn more about this format, check out our detailed documentation
The components StateManager
and Transform
are required. We'll discuss states
a bit more later, but if you want to understand this better, you can check their
documentation. For now, think of a GameObject
s
state as its look and feel.
Now that we have our avatar configured, can we see it in action? Not quite! We
need a couple of things before. First, in addition to the avatar prefab, we need
some spawn points for our players. How do we specify those? You guessed it! We
need a prefab for the spawn points1. Add the following to your
harvest.py
config:
SPAWN_POINT = {
"name": "spawn_point",
"components": [
{
"component": "StateManager",
"kwargs": {
"initialState": "spawnPoint",
"stateConfigs": [{
"state": "spawnPoint",
"groups": ["spawnPoints"],
}],
}
},
{
"component": "Transform",
},
]
}
This one is much simpler. The only critical part is the "groups"
parameter
which corresponds to the "spawnGroup"
in our Avatar component above. This is
saying that "spawnPoints"
is both, the group of the spawn point GameObject
,
as well as the locations that our avatars will use for sapwning.
Finally, we need to specify the ASCII map, the mapping of ASCII chars to
prefab name, and a mapping of prefab name to the actual prefab. We do this in
the lab2d_settings
part of the config. In the end, they should look like this:
config.substrate.lab2d_settings = {
"levelName": "harvest",
"levelDirectory":
"examples/tutorial/harvest/levels",
"maxEpisodeLengthFrames": 100,
"numPlayers": 1,
"spriteSize": 8,
"simulation": {
"map": " _ ",
"prefabs": {
"avatar": AVATAR,
"spawn_point": SPAWN_POINT,
},
"charPrefabMap": {"_": "spawn_point"},
"playerPalettes": [],
},
}
NOTE: The engine (DMLab2d) uses the term "level" instead of our term "substrate".
NOTE: You can create the map that you want, we just provided " _ "
as an
example. We will expand it later.
The main reason is because we often want to have one char to map not just to one prefab, but a collection of them, or a choice between prefabs. For this substrate we won't need this functionality, but if you are interested, read about prefab specifications.
You might have noticed we specify Component
parameters by making "kwargs"
.
These corresponds to the constructor parameters of the Component
. Some of
these are optional, and will have a default value in the constructor itself.
Whatever value is specified in Python will override the default.
For instance, you can see the arguments for the Avatar Component
here.
Running the game should produce something like the following:
You can move around with WASD
, and turn with Q
& E
... Of course, you
cannot really see which way you are going since our avatar is just a colored
square. Let's improve our visuals!
In this section we will add another prefab for walls in the substrate, and improve the avatar looks, both with sprites.
By now you should be able to guess that all that is needed to change the
appearance of an object is to... well, change its Appearance Component
.
We have some useful sprites in the
shapes.py
library.
Let's import it (don't forget to add the dependency):
from meltingpot.utils.substrates import shapes
and change our avatar's Appearance Component
to:
{
"component": "Appearance",
"kwargs": {
"renderMode": "ascii_shape",
"spriteNames": ["Avatar"],
"spriteShapes": [shapes.CUTE_AVATAR],
"palettes": [{}], # Will be overridden
"noRotates": [True],
}
},
Let's add walls to the substrate by pasting this WALL
prefab:
WALL = {
"name": "wall",
"components": [
{
"component": "StateManager",
"kwargs": {
"initialState": "wall",
"stateConfigs": [{
"state": "wall",
"layer": "upperPhysical",
"sprite": "Wall",
}],
}
},
{
"component": "Transform",
},
{
"component": "Appearance",
"kwargs": {
"renderMode": "ascii_shape",
"spriteNames": ["Wall",],
"spriteShapes": [shapes.WALL],
"palettes": [shapes.WALL_PALETTE],
"noRotates": [True],
}
},
{
"component": "BeamBlocker",
"kwargs": {
"beamType": "gift"
}
},
{
"component": "BeamBlocker",
"kwargs": {
"beamType": "zap"
}
},
]
}
Finally, we need to update our ASCII map, add the wall to the prefabs
, and a
character to the charPrefabMap
. Our lab2d_settings["simulation"]
now looks
like this:
"simulation": {
"map": """
*******
* *
* _ *
* *
* *
*******
""",
"prefabs": {
"avatar": AVATAR,
"spawn_point": SPAWN_POINT,
"wall": WALL,
},
"charPrefabMap": {"_": "spawn_point", "*": "wall"},
"playerPalettes": [],
},
And now our substrate looks like this! Much nicer.
NOTE: The walls prevent avatars passing through them because they are on the
same layer (upperPhysical
).
It's time to add some interactivity to our substrate. Start from this APPLE
prefab, and add an Edible
component to it so it provides a reward to the
player whenever it is touched.
The overall changes needed are:
- Add a
"contact": "avatar"
to the avatar'sStateConfig
- Add a prefab for the apple
a. Add two states, one for the live (active) apple, one for the wait
(dormant) one
b. Add an
Edible
component - Add the apple to the
map
,prefabs
andcharPrefabMap
Add "contact": "avatar",
to the avatar's "stateConfigs"
. It should now look
like this:
"stateConfigs": [
{"state": "player",
"layer": "upperPhysical",
"contact": "avatar",
"sprite": "Avatar",},
]
The APPLE
prefab should have two states, one where we actually show the apple,
and when touched is consumed for reward, and one in which the apple is invisible
and immaterial. You'll have to add these states to the StateManager
.
Let's start similar to our WALL
prefab, except using the shapes.LEGACY_APPLE
sprite, and the shapes.GREEN_COIN_PALETTE
. The StateManager
should have two
states. One of them (call its state
"apple"
) having a layer
and sprite
.
The other one, only a state (call it "appleWait"
). The StateManager
component should look like this:
{
"component": "StateManager",
"kwargs": {
"initialState": "apple",
"stateConfigs": [{
"state": "apple",
"layer": "lowerPhysical",
"sprite": "Apple",
}, {
"state": "appleWait",
}],
}
},
NOTE: The layer must be different than "upperPhysical"
so that the avatar
can enter it. The usual layers are background
, lowerPhysical
,
upperPhysical
, and overlay
.
The
Edible
component takes 3 kwargs
:
liveState
: The name of the state that is touchable and provides rewardwaitState
: The immaterial or deactivated state, e.g. for after consumptionrewardForEating
: The numeric reward to players who touch it when live
Try adding this component yourself. Check the solution below if you need help.
APPLE = {
"name": "apple",
"components": [
{
"component": "StateManager",
"kwargs": {
"initialState": "apple",
"stateConfigs": [{
"state": "apple",
"layer": "lowerPhysical",
"sprite": "Apple",
}, {
"state": "appleWait",
}],
}
},
{
"component": "Transform",
},
{
"component": "Appearance",
"kwargs": {
"renderMode": "ascii_shape",
"spriteNames": ["Apple",],
"spriteShapes": [shapes.LEGACY_APPLE],
"palettes": [shapes.GREEN_COIN_PALETTE],
"noRotates": [True],
}
},
{
"component": "Edible",
"kwargs": {
"liveState": "apple",
"waitState": "appleWait",
"rewardForEating": 1.0,
}
},
]
}
Like before, we need to add the new prefab to the map. Let's use "A"
as the
character in the ASCII map, and call the prefab "apple"
.
config.lab2d_settings = {
"levelName": "harvest",
"levelDirectory":
"examples/tutorial/harvest/levels",
"maxEpisodeLengthFrames": 100,
"numPlayers": 5,
"spriteSize": 8,
"simulation": {
"map": """
*******
* A *
* _ *
* A *
* A *
*******
""",
"prefabs": {
"avatar": AVATAR,
"spawn_point": SPAWN_POINT,
"wall": WALL,
"apple": APPLE,
},
"charPrefabMap": {"_": "spawn_point", "*": "wall", "A": "apple"},
"playerPalettes": [],
},
}
If a component you've added doesn't function in the way you anticipate, an easy
way to debug the substrate is to launch it with the --verbose
flag.
python examples/tutorial/harvest/play_harvest.py --verbose True
This flag will activate the verbose_fn
function in play_harvest.py
, where
you can utilize the unused_timestep
object to print out different attributes.
def verbose_fn(unused_timestep, unused_player_index: int) -> None:
So far we have only used pre-existing components. However, what if we want
functionality that doesn't yet exist? For this, we need to create a new
Component
in Lua. By convention, we put these in a file called
components.lua
in the same directory as our init.lua
. Also, we have to
require
the file in init.lua
so that the components defined there will be
available.
In this section we will create a component called DensityRegrow
that will
stochastically switch a dormant apple back to life, but only if there are other
apples in its immediate vicinity.
Let's start by creating components.lua
and putting this boilerplate in it:
local args = require 'common.args'
local class = require 'common.class'
local helpers = require 'common.helpers'
local log = require 'common.log'
local random = require 'system.random'
local meltingpot = 'meltingpot.lua.modules.'
local component = require(meltingpot .. 'component')
local component_registry = require(meltingpot .. 'component_registry')
-- DensityRegrow makes the containing GameObject switch from `waitState` to
-- `liveState` at a rate based on the number of surrounding objects in
-- `liveState` and the configured `baseRate`.
local DensityRegrow = class.Class(component.Component)
local allComponents = {
DensityRegrow = DensityRegrow,
}
component_registry.registerAllComponents(allComponents)
return allComponents
IMPORTANT: Don't forget to add a require
to your init.lua
(e.g. right
below the require
for the avatar_library
.
local components = require 'components'
Now let's define the constructor of our component. Whichever parameters we
accept here are the ones we can specify when building our prefab. For the
DensityRegrow
, we want to control the base rate of regrowth as well as the
radius defining the neighborhood. For simplicity we will just make it that the
regrow rate is directly proportional to the number of alive apples in the
neighborhood.
Our constructor looks like this:
function DensityRegrow:__init__(kwargs)
kwargs = args.parse(kwargs, {
{'name', args.default('DensityRegrow')},
-- `baseRate` indicates the base probability per frame of switching from
-- wait state to live state.
{'baseRate', args.ge(0.0), args.le(1.0)},
-- The name of the state representing the active or alive state.
{'liveState', args.stringType},
-- The name of the state representing the inactive or dormans state.
{'waitState', args.stringType},
-- The radius of the neighborhood
{'neighborhoodRadius', args.numberType, args.default(1)},
-- The layer to query for objects in `liveState`
{'queryLayer', args.stringType, args.default("lowerPhysical")},
})
DensityRegrow.Base.__init__(self, kwargs)
self._config.baseRate = kwargs.baseRate
self._config.liveState = kwargs.liveState
self._config.waitState = kwargs.waitState
self._config.neighborhoodRadius = kwargs.neighborhoodRadius
self._config.queryLayer = kwargs.queryLayer
end
Notice the use of the
args.lua
module to specify the arguments, their type, range, and default values.
Arguments with default values can be omitted in the prefab specification.
By convention, we save the parsed arguments into the self._config
table, but
you can do differently is desired.
Now we just need to specify the logic of regrowth. We do this by registering an
updater which will be called every frame that our GameObject
(containing the
DensityRegrow
component) is in its waitState
:
function DensityRegrow:registerUpdaters(updaterRegistry)
updaterRegistry:registerUpdater{
state = self._config.waitState,
updateFn = function()
local transform = self.gameObject:getComponent("Transform")
-- Get neighbors
local objects = transform:queryDiamond(
self._config.queryLayer, self._config.neighborhoodRadius)
-- Count live neighbors
local liveNeighbors = 0
for _, object in pairs(objects) do
if object:getState() == self._config.liveState then
liveNeighbors = liveNeighbors + 1
end
end
local actualRate = liveNeighbors * self._config.baseRate
if random:uniformReal(0, 1) < actualRate then
self.gameObject:setState(self._config.liveState)
end
end,
}
end
To learn more about updaters read our documentation
All that is left, is adding this component to our APPLE
prefab.
{
"component": "DensityRegrow",
"kwargs": {
"liveState": "apple",
"waitState": "appleWait",
"baseRate": 0.1,
}
},
NOTE: If you get the error:
component_registry.lua:25: The component DensityRegrow does not exist in the registry.
you might have forgotten to require
the components
in init.lua
,
or you might have skipped the footer in components.lua
with
component_registry.registerAllComponents(...)
Now that we have all the pieces we need, let's scale up our ASCII map, add some more players (and spawn points!), and make episodes last longer.
First set config.num_players = 5
. The rest of the changes go in
lab2d_settings
you can try it on your own, but if get stuck, check ours:
ascii_map = """
**********************
* AAA AAA *
* AAA A AAA A *
*AAAAA _ AAAAA _ *
* AAA AAA *
* AAA AAA *
* AAAAA _ AAAAA*
* AAA AAA *
* A A *
* AAA _ AAA _ *
**********************
"""
# Lua script configuration.
config.lab2d_settings = {
"levelName": "harvest_finished",
"levelDirectory":
"examples/tutorial/harvest/levels",
"maxEpisodeLengthFrames": 1000,
"numPlayers": 5,
"spriteSize": 8,
"simulation": {
"map": ascii_map,
...
You can also launch the interactive substrate using the player view instead of
the third person one. Simply by remove the --observation WORLD.RGB
flag:
python examples/tutorial/harvest/play_harvest.py
You should see something like this:
NOTE: You can swap between them in the interactive window by pressing TAB
.
WARNING: If you see an error like
UnboundLocalError: local variable 'game_display' referenced before assignment
,
you might have forgotten to set the individual_observation_names
to ["RGB"]
.
We provide a builder
that adapts the raw Lab2d
substrate into an interface compatible with
dm_env
. This adaptor exposes a multi-agent API, where the action and
observation specs are lists of the single-agent specs. The observation produced
by the substrate is a list of single-agent observations, one for each of the
players. Likewise, the action is provided as a list of single-agent actions, one
per player.
Footnotes
-
You can define a single prefab for spawn points, but you must have sufficient spawn points in the map for all the avatars that will use them. You can have different avatars use different spawn points. Simply create different prefabs for the spawn points with different group names, and set the avatar's
spawnGroup
parameter to the desired one. ↩