Skip to content
avova edited this page Sep 28, 2024 · 226 revisions

This page contains snippets of useful knowledge and tips related to Ballistica development that are not big enough to warrant their own page. Feel free to add your own here or expand on existing ones.

Nugget List:

Wish List:

Is there something you'd like to see here? Add it to this list or increment the 👍 count on an existing wishlist item.

  • How do I create my own Spaz type? 👍 7

Hello World: Creating a New Game Type

Let's just jump in and create a new game under the Ballistica system, shall we? This process is a little different than the old BombSquad setup, so this is a good thing to start with.

  • First, make sure you've got Ballistica working by following the steps in Getting Started. You should be able to run make prefab-debug or make prefab-release and get a running game.

  • Now create a file at src/assets/ba_data/python/mygame.py (or whatever name you want to use). Paste the following code into it:

    """Define a simple example game."""
    
    # ba_meta require api 8
    
    from __future__ import annotations
    
    from typing import TYPE_CHECKING
    
    import bascenev1 as bs
    from bascenev1lib.game.deathmatch import DeathMatchGame
    
    if TYPE_CHECKING:
        pass
    
    
    # ba_meta export bascenev1.GameActivity
    class MyGame(DeathMatchGame):
        """My first ballistica game!"""
    
        name = 'My Game!'
    
        def on_begin(self) -> None:
            super().on_begin()
            bs.screenmessage('HELLO WORLD!!!!', color=(0, 1, 0))
  • Some of this code may look familiar, but what is that weird type-checking and ba_meta stuff?

    • The __future__ and TYPE_CHECKING lines are some basic boilerplate to support our fancy new type-checking setup. You could leave these out if you want, but I would highly suggest giving type-checking a try. Learn more about what those lines do here.
    • The ba_meta lines are how we inform the Ballistica engine of what our module provides for it. Learn more about the meta tag system here.
  • The rest of the code should be pretty straightforward. We pull in the standard DeathMatchGame and make our own subclass of it that prints 'HELLO WORLD!!!!' to the screen when the game starts.

  • We now have to tell the build system to look for new files so that our script gets added to the build. To do that, we simply run make update in a terminal. See this for more about the update command. However, this command will result in the following error:

    License text not found at 'assets/src/ba_data/python/mygame.py' line 1; please correct.
    Expected text is: # Released under the MIT License. See LICENSE for details.
    NOTE: You can disable copyright checks by adding "license_line_checks": false
    to the root dict in config/localconfig.json.
    see https://ballistica.net/wiki/Knowledge-Nuggets#hello-world-creating-a-new-game-type
    

    The updater script also checks to make sure that all scripts in the game contain the standard license line, and it is complaining that they are missing here. As a modder, you shouldn't need to care about this, so you can disable this check by creating a config/localconfig.json file containing the following:

    {
      "license_line_checks": false
    }

    Now if we run make update again, it should go through. Hooray!

  • At this point, we're ready to go. Run make prefab-debug from the command line, and the game should build and launch. You should now be able to create a new game playlist, add your game to it, and see a lovely 'HELLO WORLD!!!!' on-screen when the game is played.

  • To make changes, simply edit your file (try changing the screen message from 'HELLO WORLD' to 'HELLO WORLD 2'), save the file, and re-run make prefab-debug. You should see the message change.

  • This is also a good time to try out Ballistica's new type/lint checking. These checks can catch lots of errors for you without needing to run the game.

    • In your terminal, type make check. This will run all checks on all code. This may take a while the first time it is run, but it should be much faster on subsequent runs. If you entered the code as provided here, you should see no errors.
    • Now let's add an error. Rename the ba.screenmessage() call to something incorrect like ba.zcreenmessage() and run make check again. You should see that both Mypy and Pylint inform you that module 'ba' has no 'zcreenmessage' member.
    • These sort of checks can help you write code much more efficiently since you don't need to launch the game to catch many errors. Learn more about checks here or in other tips on this page.
  • Note: in this example we added our module under assets/src/ba_data/python and used make update to incorporate it into our Ballistica build, but this is not strictly necessary. If we were to take our mygame.py file and simply drop it into, say, the mods folder on an Android device running BombSquad, it would still work. But adding it to our Ballistica build like this lets us use all the nice features such as make check and make format while working on it.

  • Enjoy!! You may now want to read through some of the remaining tips on this page to learn more about the typing system and other changes in Ballistica compared to old BombSquad.

Hello World 2: Creating a New Plugin

To follow that up, let's now create a new 'Plugin'. A plugin is a special type of mod that modifies the app in some arbitrary way. They are imported and run at specific times throughout the app lifecycle, allowing them to augment or override default behavior, and they can be explicitly enabled or disabled by the user.

Note that plugins were added in version 1.5.23, so make sure you are up to date.

For this example, we'll create a plugin that simply prints a 'Hello World' message when the app launches.

First, a bit of history: In BombSquad 1.4.x, all scripts in the mods directory were executed at launch, which made doing things such as this rather trivial, but which also led to inefficiencies and hard-to-debug errors. Now, in 1.5, all modules are only imported when they are actually needed, so we have to explicitly tell the game that we have code that it should import and run at launch. We can do this by defining a ba.Plugin type.

  • Make sure you've run the previous tutorial and are familiar with the concepts there.
  • Now create a file at assets/src/ba_data/python/myplugin.py (or whatever name you want to use), and paste the following code into it:
    """Define a simple example plugin."""
    
    # ba_meta require api 7
    
    from __future__ import annotations
    
    from typing import TYPE_CHECKING
    
    import ba
    
    if TYPE_CHECKING:
        pass
    
    
    # ba_meta export plugin
    class MyPlugin(ba.Plugin):
        """My first ballistica plugin!"""
    
        def on_app_running(self) -> None:
            ba.screenmessage('HELLO WORLD FROM MYPLUGIN!!!!', color=(0, 1, 0))
  • Make sure the file is saved, run make update to add this new file to the build, run make check if you want to make sure your code is ok, and then run make prefab-debug to build and run the game.
  • If everything worked, you should see 'HELLO WORLD FROM MYPLUGIN!!!!' printed on the screen as the game launches and it finds your new plugin. 🥳
  • Note: in this example we added our module under assets/src/ba_data/python and used make update to incorporate it into our Ballistica build, but this is not strictly necessary. If we were to take our myplugin.py file and simply drop it into, say, the mods folder on an Android device running BombSquad, it would still work. Or we could create it in a workspace. But adding it to our Ballistica build like this lets us use all the nice features such as make check and make format while working on it.

Hello World 3: Creating a New Keyboard

As a continuation of our "Hello World" series, let's create a new on-screen keyboard. Note: you can find the default one at assets/src/ba_data/python/bastd/keyboards/englishkeyboard.py

  • Create new file mathkeyboard.py in assets/src/ba_data/python/
    """Defines my math keyboard."""
    
    # ba_meta require api 6
    
    from __future__ import annotations
    
    from typing import TYPE_CHECKING
    
    import ba
    
    if TYPE_CHECKING:
        pass
    
    
    # ba_meta export keyboard
    class MathKeyboard(ba.Keyboard):
        """Math keyboard with some extra symbols."""
        name = 'Math'
        chars = [('+', '-', '*', '=', '&', '_'),
                 ('/', '%', '.', '^', '#'),
                 ('(', ')', '?', '$')]
        nums = ('1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '!', '`', '~', '@')
        pages: dict[str, tuple[str, ...]] = {}
  • Run make update and make prefab-debug.
  • You may want to turn on 'Always Use Internal Keyboard' in Settings->Advanced. By default the internal keyboard is only used for game controllers and the rest of the time it relies on hardware keyboards, the standard Android on-screen keyboard, etc.
  • Now, when you will use on-screen keyboard, you should be able to change keyboard to 'Math'.
  • Haha, looks very funny, right?

Hooray For Auto Formatting!

If you're like me, you like to keep your code consistent; perhaps to the point of slight OCD. I used to waste lots of time manually updating code whenever my style choices would evolve: adding spaces after commas in argument lists, lining multi-line statements up cleanly, etc.

A few developments have helped cut down on time spent on things like this. For one, adopting coding standards such as Pep-8 has helped avoid wasting time noodling code styling, since most everything from spacing to capitalization is clearly defined in these standards. Sometimes its nice to just have a spec to follow instead of going back and forth deciding what looks nicest.

More importantly though, auto-formatting has been a life-changer. I now rarely even think about formatting and just run auto-formatting constantly as part of my workflow. This has been set up for the project to conform to our chosen standards and can be easily run from the root level Makefile.

So now you can write a completely ugly bit of code such as this:

self._score_text=ba.NodeActor(ba.newnode('text',attrs={'h_attach':'left','v_attach':'top','h_align':'right','v_align':'center','maxwidth':maxwidth,'vr_depth': 2,'scale':self._scale*0.9,'text':'','shadow':1.0 if vrmode else 0.5,'flatness':flatness,'color':clr}))

And then save your file and run 'make format' from the command line at the project root. (this may take a while the first time you run it but should be nearly instantaneous in subsequent runs).

Reload your file (if your editor doesn't do this for you) and you'll now see nicely formatted code:

self._score_text = ba.NodeActor(
    ba.newnode(
        'text',
        attrs={
            'h_attach': 'left',
            'v_attach': 'top',
            'h_align': 'right',
            'v_align': 'center',
            'maxwidth': maxwidth,
            'vr_depth': 2,
            'scale': self._scale * 0.9,
            'text': '',
            'shadow': 1.0 if vrmode else 0.5,
            'flatness': flatness,
            'color': clr,
        },
    )
)

Ballistica's Python formatting is handled by black. It generally does a good job, but there may be cases such as formatted data where you want it to leave some code alone. You can tell black to ignore sections of code by placing disable and enable statements around it:

   # fmt: off
    archs_x86 = {
        'x86': {'prefix': 'x86-', 'libmain': 'libmain_x86.so'},
        'x86-64': {'prefix': 'x86_64-', 'libmain': 'libmain_x86-64.so'}
    }
    archs_arm = {
        'arm': {'prefix': 'arm-', 'libmain': 'libmain_arm.so'},
        'arm64': {'prefix': 'aarch64-', 'libmain': 'libmain_arm64.so'},
    }
    # fmt: on

Auto-formatting is set up so trailing commas can be used to hint at code layout. Use your best judgement on what is more readable for your particular set of data, function call, etc. Remember, however, that in certain cases a trailing comma can change the meaning of code: (1) is simply an int within (unnecessary) parentheses while (1,) is a tuple containing a single int.

# Running 'make format' will give the following results
# without and with a trailing comma, respectively:

mylist = ['first', 'second', 'third', 'fourth']

mylist = [
    'first',
    'second',
    'third',
    'fourth',
]

Mypy: Getting a Variable's Type

Type checking helps you write better code by keeping track of your variable and function types for you and informing you if it looks like you are doing something illegal with them (trying to add an int and a string, trying to pass a string argument to a function that expects a float, etc.). Without type checking, errors such as this can go unnoticed until the next time you run the code.

When using Mypy to perform static type-checking on Python code, one of the most basic and important things to do is ask Mypy what type it thinks a variable currently is.

Say we have the following code:

value = some_mysterious_function()

How do we know what type Mypy thinks 'value' is? We could guess this ourself by looking up the definition of some_mysterious_function(), but it is often better to be sure and just ask Mypy directly. We can do that by adding the following special fake function call to our code which is understood by Mypy:

value = some_mysterious_function()
reveal_type(value)

Now we can run make mypy in a terminal from the project root, and we should see output such as this:

Running Mypy (incremental)...
path/to/this/python_script.py:45: note: Revealed type is 'builtins.int'
Mypy: fail.
make: *** [mypy] Error 255

Ok, Mypy says 'value' is an int. Good to know! So if we were to add a statement such as value += 'foo', then Mypy would kindly inform us that ints and strings cannot be added. Without type checking, an error such as that might go unnoticed until the next time the code gets run, and who knows when that might be. Hooray for static type checking!

Be sure to remove the reveal_type() line when you are done with it, otherwise the code will error at runtime due to that not being an actual Python function.

Debug-Only Python Code

With the Ballistica project it is now easy to switch between debug and release builds of the game. (make prefab-debug vs make prefab-release). Debug builds perform much more error checking than release builds; this makes them very useful during development, with the tradeoff that they can be significantly slower and less efficient than release builds.

You can take advantage of having two build types by adding extra safety checks that run only in debug builds. There are a few ways to do this.

The first is the Python's 'assert' statement. These will be evaluated in debug builds and completely stripped out of release builds, so think of them as 'free'. They are a great way to make sure things are as you expect them to be.

my_thingie = make_a_thingie()
assert my_thingie is not None. # <-- Throws an AssertionError if not True.

As a nice side-effect, assert statements are also quite useful for Mypy type-checking. (see Mypy: Getting a Variable's Type)

# Dynamically evaluate a string to create an object (in this case an int).
val = eval('123')

# If we run 'make mypy' from the project root, Mypy will tell
# us that val is 'Any' here because eval() can return anything.
# WE know it will be an int by looking at the string value getting
# passed, but Mypy is not quite that smart (at least not yet).
reveal_type(val)  

# We expect val to be an int; let's make sure that's the case.
assert isinstance(val, int). 

# Because of the above assert statement, Mypy knows val must be an int
# at this point in the code, so 'make mypy' will show 'builtins.int' here.
reveal_type(val). 

Another way to generate debug-only code is with Python's special __debug__ variable. Code such as this will run in debug builds of the game but will be completely stripped out of release builds, again making it 'free'.

if __debug__:
    for node in all_my_nodes:
        do_some_expensive_sanity_checks(node)

Note that only statements with the exact form of if __debug__: appear to get optimized out; more complex expressions such as if __debug__ and ENABLE_MY_DEBUG_CHECKS: will remain and get evaluated at runtime (even if they logically can never evaluate to True).

Always remember: it is extremely important that no actual logic be affected as a side-effect of code such as this since it will never run in release builds. The code should only perform testing and validation.

Explicitly Disable Code

Sometimes it can be handy to have code that is never run, even in debug builds; it is simply available to temporarily flip on during debugging/testing or is being prepared for enabling at a later date.

One way to do this is simply to comment the code out.

# Uncomment this code if needed for testing.
# run_extra_expensive_debug_tests()

The problem with this is that the code will not be type-checked. Also, commented-out code looks like it is no longer needed and is easy to accidentally delete during tidying passes. So this is not ideal.

A better way is to disable it explicitly with code:

# Enable this code if needed for testing.
if False:
    run_extra_expensive_debug_tests()

The problem with this is that linters and type checkers will complain about this statement since using constants in if-statements is often a mistake or can be optimized out. Also, Mypy will see that the run_extra_expensive_debug_tests() can not possibly be reached and will not type-check it. (You can conclude that Mypy is not checking code when you place a reveal_type() statement in it and Mypy says nothing about it). So what is the answer here?

While it is slightly hacky, it is currently possible to quiet all these complaints simply by making the expression slightly more complex (in this case, instantiating a bool with a bool):

if bool(False):
    run_extra_expensive_debug_tests()

This results in no more linter complaints and Mypy type-checking the code. Hooray!

If linters ever get smart enough to start complaining about this, we can always implement some sort of function for this purpose that linters won't complain about; something like: explicit_bool(value: bool) -> bool. In fact this exact thing is done in the C++ layer. But our little bool(False) trick works for now.

Beware The 'Any' Type

Once you have familiarized yourself with checking variable types, the next important thing to remember with type checking is to make sure things actually have types. There is a special 'Any' type which Mypy will give variables when it doesn't know what type they are. Mypy will never generate errors for 'Any' variables since it doesn't know what is or is not valid for those objects, and thus that code will not gain the benefits of type checking. For type checking to work well, it is important to watch out for 'Any' values and inform Mypy what types those objects are supposed to be.

One common example is objects contained in a dict:

def handle_thingies(thingies: dict):
   # This argument declaration doesn't specify the types the 'thingies' dict
   # contains, so Mypy will consider its members to be 'Any'.
   # If we had instead declared 'thingies: dict[str, int]' then Mypy
   # would assume the dict contains string keys pointing to int values.

   thingie1 = thingies['first']
   reveal_type(thingie1)  # <-- Mypy will tell us thingie1 is 'Any' here.

   # Now if we do something illegal with thingie1, Mypy will not warn us because
   # it doesn't know what type thingie1 is, so it has no idea what is ok to do.
   # We won't know about this problem until we hit it at runtime, which is what
   # type-checking is supposed to save us from.
   thingie1.method_that_doesnt_exist()  # <-- 'this is fine!' says Mypy. :(

   # We can improve the situation by telling Mypy what type thingie1 is. One
   # way to do this is with the 'assert' statement, (as discussed in a previous
   # example). This has the added benefit of double-checking at runtime that
   # the object is actually the type we think it is. (but only in debug builds,
   # which means we can consider such statements to be 'free' since they have
   # no impact on the performance of our release builds)
   assert isinstance(thingie1, Thingie)
   reveal_type(thingie1)  # <-- Now Mypy knows thingie1 is a Thingie; hooray!
   thingie1.method_that_doesnt_exist()  # <-- Now Mypy will correctly error here.

   # We could also give our variable a type when we declare it.
   # Mypy considers everything in thingies to be 'Any' so it just takes our
   # word for it that this assignment is valid and a Thingie.
   thingie2: Thingie = thingies['second']
   reveal_type(thingie2)  # <-- Mypy says this is a Thingie.

   # Note that it could still be good to add an assert() statement as a runtime
   # sanity check in this case, since Mypy thinking something is a certain type
   # does not necessarily mean that's what it will be at runtime. (someone could
   # technically pass in a dict of non-thingie objects). Liberal use of assert()
   # statements can help make sure the type-checking world and runtime world are
   # properly in sync.
   assert isinstance(thingie2, Thingie)  # Just checking!

   # One more tool for wrangling types is the 'cast' function in the typing module.
   # At runtime this simply returns the object passed to it, but in type checking
   # it changes the type an object is considered to be.
   from typing import cast
   child_thingies = thingies['children']
   reveal_type(child_thingies)  # <-- Once again Mypy thinks this is 'Any'.
   casted = cast(list[Thingie], child_thingies)
   reveal_type(casted)  # <-- Mypy says this is a list[Thingie].

So the basic moral of the story is: hunt down and destroy all occurrences of 'Any' in your code and you will get the maximum benefit from type checking.

Python Type-Checking Boilerplate

You may notice certain things at the top of most Ballistica Python modules; namely this code:

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    import xxx

The following is an explanation of why that code exists:

Ballistica uses Mypy for static typing. Static typing lets us catch a large number of issues in our code without having to run it. But for this to work, we need to add type annotations to our functions and variables.

# So instead of this:
def get_next_number(input):
    return input + 1

# We need this:
def get_next_number(input: int) -> int:
    return input + 1

Pretty simple so far. However, sometimes we can't annotate things because the names we want to use don't exist yet. Consider the following where we want to create a 'my_thingie' variable that can either point to None or a Thingie instance: (aside: In Python 3.10 it is now possible to rewrite Optional[Thingie] as Thingie | None but we're going to do it the old way here for the purposes of this lesson)

from typing import Optional

my_thingie: Optional[Thingie] = None

class Thingie:
    pass

If we run this code, it will fail, saying 'Thingie' is not defined on the line where we create my_thingie. This is true; it won't exist until Python executes the code 2 lines down from it which defines class Thingie. And yes, in this case we could simply move the class definition above the variable definition, but ignore that for the sake of this exercise.

Python type annotations work around problems like this by allowing forward declarations using strings:

from typing import Optional

my_thingie: "Optional[Thingie]" = None

class Thingie:
    pass

Now the annotation is just a string so nothing is undefined, but the type-checker is still able to understand it. This code will work, but it makes the annotation look ugly. Luckily, because Ballistica uses Python 3.7+, we can make this cleaner by using Postponed Evaluation of Annotations. This essentially turns all annotations into strings under-the-hood, so we can write more natural looking code but still forward-declare things. This also makes things more efficient since Python doesn't have to evaluate complex annotation code such as list[dict[str, Optional[Thingie]] at runtime; it simply sees that whole thing as a string. This sort of behavior may become the default for Python sometime in the future, but for now we have to use a __future__ import to get it.

So now our code looks like:

from __future__ import annotations

from typing import Optional

my_thingie: Optional[Thingie] = None

class Thingie:
    pass

This code runs and isn't too ugly (aside from the __future__ bit which we will eventually be able to get rid of). But there is one final issue. Running make check on this code will give us an error, with Pylint telling us that the imported 'Optional' is unused here. This is technically true because my_thingie's Optional[Thingie] annotation is really just a string now; no actual runtime code is using Optional. However it is needed by the type-checker. If we remove that line and run make check again, then Mypy type checking will fail saying Optional is not defined. So how do we make everyone happy here?

The answer is to define such things only for the type checker. We can do that using a special TYPE_CHECKING constant provided by the typing module. So the final form of our code is this:

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
   from typing import Optional

my_thingie: Optional[Thingie] = None

class Thingie:
    pass

So now everyone is happy; we're not being wasteful at runtime by importing things we don't use, but the type checker is still able to find everything it needs. This leaves us with a few lines of boilerplate code per module, but it gives us a good type-checking foundation. With this setup we can isolate imports and other code that is only used for type-checking, keep forward-declarations clean and readable, and minimize the impact of type-checking on runtime performance.

This may all sound ultra-complicated, but actually becomes pretty intuitive when using the make check commands. Basically, if Pylint tells you that something you imported is unused, take it out. And then if Mypy tells you that it needs the thing you just took out, put it back under the if TYPE_CHECKING section where only Mypy will see it. Then everyone should be happy.

Data Classes are Handy

Python 3.7 introduced data classes, so we now have access to these in Ballistica. These work very well with our type-checking setup, allowing us to easily define little bits of data that get passed around. We could also write full classes for these purposes, but that is often overkill and would require extra boilerplate code.

Here is how we might have done some things before, using simple dicts and values:

# Hmm; let's keep track of some spawn points for our mini-game or whatnot...
spawn_points = []
spawn_points.append({'position':(0.0, 0.0, 0.0),
                     'team_id':0})
spawn_points.append({'position':(0.0, 0.0, 0.0),
                     'team_id':1,
                     'color':(1.0, 0.0, 0.0)})

# That works, but it can be easy to introduce subtle errors that
# won't be noticed until runtime:

# Oops; wrong name for the dict entry.
spawn_something_at_position(spawn_points[0]['pos'])

# Oops; our first spawn point doesn't contain 'color'; we'd need to
# check for that case and substitute a fallback value here.
flash_at_position(spawn_points[0]['position'],
                  color=spawn_points[0]['color'])

Using data classes, we can now write this code in a much safer manner without much extra work:

from dataclasses import dataclass

@dataclass
class SpawnPoint:
    position: tuple[float, float, float]
    team_id: int
    color: tuple[float, float, float] = (1.0, 0.0, 0.0)

spawn_points: list[SpawnPoint] = []
spawn_points.append(SpawnPoint(position=(0.0, 0.0, 0.0), team_id=0)

# Now running `make check` will catch all sorts of errors for us without
# having to run the code:

# Error: required 'team_id' arg not provided.
spawn_points.append(SpawnPoint(position=(0.0, 0.0, 0.0))

# Error: SpawnPoint has no 'pos' attr.
spawn_something_at_position(spawn_points[0].pos)

# And this WILL work now because SpawnPoint gets a 'color' value
# by default even if it wasn't passed in the constructor.
flash_at_position(spawn_points[0].position,
                  color=spawn_points[0].color)

Hopefully this shows how much safer code is when defined in this way. It makes it possible to add, remove, or rename attributes from SpawnPoint later and instantly see exactly which places in the code need to be changed, and it also just feels cleaner and more readable without so many brackets and quoted string values everywhere.

Enums are Handy

Python 3.4 introduced enumerations, so we now have access to these in Ballistica. These can work well when paired with our type-checking setup, allowing us to write safer code.

Consider the following situation: we might want to write a mini-game that involves spawning bombs at particular places in a map. In the old system we may have written something like this:

def spawn_bomb(where):
    if where == 'top_left':
        pos = (-10, 0, 10)
    elif where == 'top_right':
        pos = (10, 0, 10)
    elif where == 'bottom_left':
        pos = (-10, 0, -10)
    elif where == 'bottom_right':
        pos = (-10, 0, 10)
    else:
        raise ValueError(f'Invalid where: {where}')
    do_spawn_bomb_at_position(pos)

# This first line will work, but the second will error at runtime.
spawn_bomb('top_left')
spawn_bomb('center_left')  # OOPS; not a valid value.

That code isn't terrible, but if we want to modify our mini-game with a different set of 'where' options then we have to carefully go through any code that calls spawn_bomb() to make sure none are passing no-longer-valid string values. This is exactly the sort of thing that type-checking can do for us.

Rewriting this to be type-safe using enums looks like:

from enum import Enum

from typing_extensions import assert_never

class SpawnPos(Enum):
    TOP_LEFT = 'tl'
    TOP_RIGHT = 'tr'
    BOTTOM_LEFT = 'bl'
    BOTTOM_RIGHT = 'br'

def spawn_bomb(where: SpawnPos) -> None:
    if where is SpawnPos.TOP_LEFT:
        pos = (-10, 0, 10)
    elif where is SpawnPos.TOP_RIGHT:
        pos = (10, 0, 10)
    elif where is SpawnPos.BOTTOM_LEFT:
        pos = (-10, 0, -10)
    elif where is SpawnPos.BOTTOM_RIGHT:
        pos = (-10, 0, 10)
    else:
        assert_never(where))
    do_spawn_bomb_at_position(pos)

# This time around, the error on the second line gets picked up
# by type-checking; no need to run the code.
spawn_bomb(SpawnPos.TOP_LEFT)
spawn_bomb(SpawnPos.CENTER_LEFT)  # OOPS; not a valid value (says Mypy).

Now make check should ensure that all calls to spawn_bomb() are getting passed valid SpawnPos values, so if we add or remove values from SpawnPos we can instantly see which code needs to be updated.

As another nice bonus, we can also use the assert_never() call to ensure that we've covered all possible cases in code. The type checker can see if it is logically possible for code to reach the assert_never(where) line and will error if it is. This way, if we add a new SpawnPos value we'll be informed that we need to fix this code to handle the new case without having to run the code.

The values associated with enums can have basic types such as int, str, etc. and can be useful when storing enums as JSON, passing them over the network, etc.

enumval = SpawnPos.TOP_LEFT  # This is an enum object.
rawval = spawnpos.value  # This gives us the string value 'tl'.
enumval2 = SpawnPos(rawval)  # Recreate SpawnPos.TOP_LEFT from 'tl'.

See the enumerations docs for more information.

Match is Handy

Python 3.10 introduced the match statement. It seems quite powerful, as you'll see if you scan through the docs. However one nice simple use case is to exhaustively deal with Enum values, similar to what we just did above.

As an example, let's continue what we were doing above; picking values based on some enum.

def get_spawn_coords(where: SpawnPos) -> tuple[float, float, float]:
    """Return exact coords for our different spawn options."""
    match where:
        case SpawnPos.TOP_LEFT:
            return (-10, 0, 10)
        case SpawnPos.TOP_RIGHT:
            return (10, 0, 10)
        case SpawnPos.BOTTOM_LEFT:
            return (-10, 0, -10)
        case SpawnPos.BOTTOM_RIGHT:
            return (-10, 0, 10)

Now, if we don't cover all Enum cases, Mypy will complain about a missing return value, nicely warning us when there are new cases we need to add values for. While this code is probably not any more efficient than the if/then/assert_never approach that we used above, it is a bit more elegant looking and less verbose.

Avoid Reference Loops

Python has two ways of cleaning up objects: simple reference counting and a cyclical garbage collector. It is useful to know some details about how these work, since some behavior in Ballistica can depend on it. Activities, for example, will not cleanly exit while any objects hold references to them, and Actors will automatically 'die' when no longer referenced.

The following code demonstrates simple reference counting:

class Foo:
    """A simple class that prints when created or destroyed."""

    def __init__(self):
        print('Creating a Foo!')

    def __del__(self):
        print('Destroying a Foo!')


# Create a Foo and store a reference to it in
# variable 'my_foo' which will keep it alive.
my_foo = Foo()  # <-- prints 'Creating a Foo!'

# Assign my_foo to something else, which drops the
# Foo instance's reference count to zero, and thus
# it dies. We could also do the same thing by
# deleting the my_foo variable (`del my_foo`).
my_foo = None  # <-- prints 'Destroying a Foo!'

This behavior is generally clean and predictable. However, there are some cases where it does not work. Consider the following:

# Create 2 Foos and store in each a reference to the other one.
foo_a = Foo()  # <-- prints 'Creating a Foo!'
foo_b = Foo()  # <-- prints 'Creating a Foo!'
foo_a.other_foo = foo_b
foo_b.other_foo = foo_a

# Uh oh; this doesn't seem to kill our Foos anymore.
foo_a = foo_b = None # <-- Nothing printed!!

In this case, the reference counts of both Foo objects are still at 1 at the end because they each hold a reference to each other. they will never reach zero. This is called a 'reference loop' and can lead to memory leaks or other problems.

Normally, Python covers cases such as this with its 'cyclical garbage collector' which runs periodically and can detect these cases and kill them as a group. It is important to know, however, that the cyclical garbage collector is disabled at most times in Ballistica (only running at specific times when hitches won't be noticed such as between rounds). This is to reduce overhead and unpredictability and prevent hitches during gameplay.

The fact that cyclical garbage collection is disabled most of the time means you must be a bit extra careful with references in order to avoid reference loops and leaks:

  • Adding simple print statements to a class' __init__() and __del__() methods during testing (like we did above with the Foo class) is a good way to make sure things are getting cleaned up when you expect they should be. If you don't see your __del__() message printing where you expect it to, you may have a leak.

  • The ba.verify_object_death() function can also be used for this purpose. You can call this on an object just before you expect it to be freed, and a few seconds later it will print a warning if the object still exists (along with info on what is still referencing it).

  • Weak references can be a good way to prevent reference loops. Weak references will not keep their target objects alive but will still allow access to them if they exist. If you have two objects that need to reference each other, consider which one should be the 'owner' and give that one a strong reference to the 'owned' object. Then give the 'owned' object a weak reference back to the owner. This way, the owner object will be free to die when no longer referenced, and the owned object will be released alongside it.

    import weakref
    foo_owner = Foo()  # <-- prints 'Creating a Foo!'
    foo_owned = Foo()  # <-- prints 'Creating a Foo!'
    foo_owner.other_foo = foo_owned
    foo_owned.other_foo = weakref.ref(foo_owner)
    
    # Now when we clear these references, the owner object
    # will die because no references to it remain (the owned
    # object only holds a weak reference), and then the owned
    # object will die because owner's reference is released
    # as it dies. Hooray!
    foo_owner = foo_owned = None  # <-- prints 'Destroying a Foo!' twice

Makefile Autocompletion

Ballistica's main Makefile is set up to list info about its available targets when you simply type make or make help. However, it can also be handy to set up your shell to allow autocompleting target names. If you are using zsh (the default on Mac as of 10.15 Catalina), you can add this to your .zshrc file to enable it:

autoload -U compinit
compinit

Once you restart your shell, you should be able to type something like make prefab- and hit tab to see a list of all matching targets.

ericf@MacBook-Fro ballistica % make prefab-
prefab-debug                  prefab-mac-release
prefab-debug-build            prefab-mac-release-build
prefab-linux-debug            prefab-release
prefab-linux-debug-build      prefab-release-build
prefab-linux-release          prefab-windows-debug
prefab-linux-release-build    prefab-windows-debug-build
prefab-mac-debug              prefab-windows-release
prefab-mac-debug-build        prefab-windows-release-build

The same functionality should be possible with other shells such as bash, but I will leave that as an exercise for the user. Update: Actually, in testing Linux distros such as Ubuntu, it appears that Makefile autocompletion works there 'out of the box'. Woohoo!

Python Cache Files Gotcha

TLDR: If directly editing .py files installed with the game, make sure to blow away their .pyc files first.

When Python imports a module, it attempts to first create an intermediate 'compiled' .pyc file from the raw .py script file. This compiled version can then be reused to import the module more efficiently the next time it is used. In old 2.x versions of Python these .pyc files were created alongside the .py files, and in modern 3.x versions they are placed in a __pycache__ directory alongside the .py files.

Traditionally, Python either recreates a .pyc file or ignores it whenever the timestamp on the corresponding .py file differs from it. This generally does the right thing as long as Python has write access to the .pyc file, keeping the .pyc up to date whenever the .py changes. However, in cases such as game distributions, Python might not have write access to the .pyc files, and bundled .pyc files might end up with timestamps that differ from their .py files as a result of the install process. The combination of these can lead to inefficiencies as Python repeatedly tries (and fails) to update the .pyc files on disk. See PEP 552 for more details.

Ballistica now avoids this problem by creating optimized 'unchecked hash' .pyc files as part of its build process. 'Release' builds of the game will look for .opt-1.pyc files for any loaded module, and if one is found, it will always be used, even if the corresponding .py file is subsequently changed. This means these .opt-1.pyc files must be explicitly updated instead of relying on Python to do so.

You don't need to worry about this if you are using Ballistica Makefile targets such as make prefab-release; .opt-1.pyc files will be automatically regenerated as part of these builds. But if you are hacking on scripts directly in an installed copy of the game, be aware that you will need to blow away the script's existing .opt-1.pyc file or your edits will never be seen by the game. (though you should only need to do this once; any new .opt-1.pyc files written by the game should be timestamp based)

Note that this only applies to the release build of the game. Debug builds still use plain old timestamp-based .pyc files and will continue to exhibit the classic behavior.


<< Meta Tag System       Workspaces >>