Step 5 - Finishing Touches#

This is the final step! We'll be making quality-of-life changes to the game to make it play more like a real platformer.

Important

This step covers general game development concepts that are not unique to rubato (grounding detection, camera scrolling, etc). We will not be going over how these work, rather we will be focusing on how to implement them in our game. A quick Google search on any of these topics is a great place to start learning.

Jump Limit#

Right now, when you move around, you'll find that you quickly run out of jumps. This is because we implemented a 2 jump limit. However, once you run out of jumps, you can't do anything to reset your jump counter. We want this counter to be reset whenever you land on the ground. To do that, we will add a ground detection hitbox to the player, making sure to set the trigger parameter to true.

Making a hitbox a trigger prevents the hitbox from resolving collisions in the rubato physics engine. It will still detect overlap and call the relevant callbacks. We will define a player_collide callback that will be called when the player's ground detector collides. When this happens, we use the provided collision Manifold to make sure the other collider is a ground hitbox, that we are not already grounded, and that we are indeed falling towards the ground. That code looks like this:

In shared.py, add the following code:

shared.py#
16player = rb.GameObject(
17    pos=rb.Display.center_left + rb.Vector(50, 0),
18    z_index=1,
19)
20
21# Create animation and initialize states
22p_animation = rb.Spritesheet.from_folder(
23    path="files/dino",
24    sprite_size=rb.Vector(24, 24),
25    default_state="idle",
26)
27p_animation.scale = rb.Vector(4, 4)
28p_animation.fps = 10  # The frames will change 10 times a second
29player.add(p_animation)  # Add the animation component to the player
30
31player.add(
32    # add a hitbox to the player with the collider
33    rb.Rectangle(width=40, height=64, tag="player"),
34    # add a ground detector
35    rb.Rectangle(
36        width=34,
37        height=2,
38        offset=rb.Vector(0, -32),
39        trigger=True,
40        tag="player_ground_detector",
41    ),
42    # add a rigidbody to the player
43    rb.RigidBody(
44        gravity=rb.Vector(y=rb.Display.res.y * -1.5),
45        pos_correction=1,
46        friction=1,
47    ),
48    # add custom player component
49    player_comp := PlayerController(),
50)

In player_controller.py we get our ground detector and set its on_collide and on_exit callbacks:

player_controller.py#
 7    def setup(self):
 8        # Called when added to Game Object.
 9        # Specifics can be found in the Custom Components tutorial.
10        self.initial_pos = self.gameobj.pos.clone()
11
12        self.animation: rb.Animation = self.gameobj.get(rb.Animation)
13        self.rigid: rb.RigidBody = self.gameobj.get(rb.RigidBody)
14
15        rects = self.gameobj.get_all(rb.Rectangle)
16        self.detector = [r for r in rects if r.tag == "player_ground_detector"][0]
17        self.detector.on_collide = self.ground_detect
18        self.detector.on_exit = self.ground_exit
19
20        # Tracks the number of jumps the player has left
21        self.jumps = 2
22        # Tracks the ground state
23        self.grounded = False
24
25        rb.Radio.listen(rb.Events.KEYDOWN, self.handle_key_down)
26
27    def ground_detect(self, col_info: rb.Manifold):
28        if "ground" in col_info.shape_b.tag and self.rigid.velocity.y >= 0:
29            if not self.grounded:
30                self.grounded = True
31                self.jumps = 2
32                self.animation.set_state("idle", True)
33
34    def ground_exit(self, col_info: rb.Manifold):
35        if "ground" in col_info.shape_b.tag:
36            self.grounded = False

Camera Scroll#

In your testing, you may have also noticed that you are able to walk past the right side of your screen. This is because there is actually more level space there! Remember that we set our level to be 120% the width of the screen. Lets use rubato's built-in lerp function to make our camera follow the player.

player_controller.py#
38    def update(self):
39        # Runs once every frame.
40        # Movement
41        if rb.Input.key_pressed("a"):
42            self.rigid.velocity.x = -300
43            self.animation.flipx = True
44        elif rb.Input.key_pressed("d"):
45            self.rigid.velocity.x = 300
46            self.animation.flipx = False
47
48    # define a custom fixed update function
49    def fixed_update(self):
50        # have the camera follow the player
51        current_scene = rb.Game.current()
52        camera_ideal = rb.Math.clamp(
53            self.gameobj.pos.x + rb.Display.res.x / 4,
54            rb.Display.center.x,
55            shared.level1_size - rb.Display.res.x,
56        )
57        current_scene.camera.pos.x = rb.Math.lerp(
58            current_scene.camera.pos.x,
59            camera_ideal,
60            rb.Time.fixed_delta / 0.4,
61        )

lerp and clamp are both built-in methods to the Math class. Note that we've used Time.fixed_delta, which represents the time elapsed since the last update to the physics engine, in seconds. This is to make our camera follow the player more smoothly, in line with the fps.

Final Player Controller Touches#

We currently only change the animation when the player jumps. Lets add some more animations when the player is moving left and right.

player_controller.py#
38    def update(self):
39        # Runs once every frame.
40        # Movement
41        if rb.Input.key_pressed("a"):
42            self.rigid.velocity.x = -300
43            self.animation.flipx = True
44        elif rb.Input.key_pressed("d"):
45            self.rigid.velocity.x = 300
46            self.animation.flipx = False
47
48        # Running animation states
49        if self.grounded:
50            if self.rigid.velocity.x in (-300, 300):
51                if rb.Input.key_pressed("shift") or rb.Input.key_pressed("s"):
52                    self.animation.set_state("sneak", True)
53                else:
54                    self.animation.set_state("run", True)
55            else:
56                if rb.Input.key_pressed("shift") or rb.Input.key_pressed("s"):
57                    self.animation.set_state("crouch", True)
58                else:
59                    self.animation.set_state("idle", True)

Let's also add a reset function. If the player falls off the level or presses the reset key ("r" in this case), we want to place them back at the start of the level.

player_controller.py#
54        # Running animation states
55        if self.grounded:
56            if self.rigid.velocity.x in (-300, 300):
57                if rb.Input.key_pressed("shift") or rb.Input.key_pressed("s"):
58                    self.animation.set_state("sneak", True)
59                else:
60                    self.animation.set_state("run", True)
61            else:
62                if rb.Input.key_pressed("shift") or rb.Input.key_pressed("s"):
63                    self.animation.set_state("crouch", True)
64                else:
65                    self.animation.set_state("idle", True)
66
67        # Reset
68        if rb.Input.key_pressed("r") or self.gameobj.pos.y < -550:
69            self.gameobj.pos = self.initial_pos.clone()
70            self.rigid.stop()
71            self.grounded = False
72            rb.Game.current().camera.pos = rb.Vector(0, 0)

Finally, let's add a little bit of polish to the player movement in the form of friction. This will make the player feel a little more grounded.

player_controller.py#
38    def update(self):
39        # Runs once every frame.
40        # Movement
41        if rb.Input.key_pressed("a"):
42            self.rigid.velocity.x = -300
43            self.animation.flipx = True
44        elif rb.Input.key_pressed("d"):
45            self.rigid.velocity.x = 300
46            self.animation.flipx = False
47        else:
48            if not self.grounded:
49                self.rigid.velocity.x = 0
50                self.rigid.friction = 0
51            else:
52                self.rigid.friction = 1

To Conclude#

That's it! You've finished your first platformer in rubato!

This was just the tip of the iceberg of what rubato can do.

If you got lost, here's the full code, just for kicks:
main.py#
 1import rubato as rb
 2
 3# initialize a new game
 4rb.init(
 5    name="Platformer Demo",  # Set a name
 6    res=(1920, 1080),  # Set the window resolution (in pixels).
 7    fullscreen=True,  # Set the window to be fullscreen
 8)
 9
10import main_menu
11import level1
12
13rb.Game.set_scene("main_menu")
14
15# begin the game
16rb.begin()
shared.py#
 1import rubato as rb
 2from player_controller import PlayerController
 3
 4##### MISC #####
 5level1_size = int(rb.Display.res.x * 1.2)
 6
 7##### COLORS #####
 8
 9platform_color = rb.Color.from_hex("#b8e994")
10background_color = rb.Color.from_hex("#82ccdd")
11win_color = rb.Color.green.darker(75)
12
13##### PLAYER PREFAB #####
14
15# Create the player and set its starting position
16player = rb.GameObject(
17    pos=rb.Display.center_left + rb.Vector(50, 0),
18    z_index=1,
19)
20
21# Create animation and initialize states
22p_animation = rb.Spritesheet.from_folder(
23    path="files/dino",
24    sprite_size=rb.Vector(24, 24),
25    default_state="idle",
26)
27p_animation.scale = rb.Vector(4, 4)
28p_animation.fps = 10  # The frames will change 10 times a second
29player.add(p_animation)  # Add the animation component to the player
30
31player.add(
32    # add a hitbox to the player with the collider
33    rb.Rectangle(width=40, height=64, tag="player"),
34    # add a ground detector
35    rb.Rectangle(
36        width=34,
37        height=2,
38        offset=rb.Vector(0, -32),
39        trigger=True,
40        tag="player_ground_detector",
41    ),
42    # add a rigidbody to the player
43    rb.RigidBody(
44        gravity=rb.Vector(y=rb.Display.res.y * -1.5),
45        pos_correction=1,
46        friction=1,
47    ),
48    # add custom player component
49    player_comp := PlayerController(),
50)
51
52##### SIDE BOUDARIES #####
53
54left = rb.GameObject(pos=rb.Display.center_left - rb.Vector(25, 0)).add(
55    rb.Rectangle(width=50, height=rb.Display.res.y),
56)
57right = rb.GameObject().add(rb.Rectangle(width=50, height=rb.Display.res.y))
player_controller.py#
 1import rubato as rb
 2import shared
 3
 4
 5class PlayerController(rb.Component):
 6
 7    def setup(self):
 8        # Called when added to Game Object.
 9        # Specifics can be found in the Custom Components tutorial.
10        self.initial_pos = self.gameobj.pos.clone()
11
12        self.animation: rb.Animation = self.gameobj.get(rb.Animation)
13        self.rigid: rb.RigidBody = self.gameobj.get(rb.RigidBody)
14
15        rects = self.gameobj.get_all(rb.Rectangle)
16        self.detector = [r for r in rects if r.tag == "player_ground_detector"][0]
17        self.detector.on_collide = self.ground_detect
18        self.detector.on_exit = self.ground_exit
19
20        # Tracks the number of jumps the player has left
21        self.jumps = 2
22        # Tracks the ground state
23        self.grounded = False
24
25        rb.Radio.listen(rb.Events.KEYDOWN, self.handle_key_down)
26
27    def ground_detect(self, col_info: rb.Manifold):
28        if "ground" in col_info.shape_b.tag and self.rigid.velocity.y >= 0:
29            if not self.grounded:
30                self.grounded = True
31                self.jumps = 2
32                self.animation.set_state("idle", True)
33
34    def ground_exit(self, col_info: rb.Manifold):
35        if "ground" in col_info.shape_b.tag:
36            self.grounded = False
37
38    def update(self):
39        # Runs once every frame.
40        # Movement
41        if rb.Input.key_pressed("a"):
42            self.rigid.velocity.x = -300
43            self.animation.flipx = True
44        elif rb.Input.key_pressed("d"):
45            self.rigid.velocity.x = 300
46            self.animation.flipx = False
47        else:
48            if not self.grounded:
49                self.rigid.velocity.x = 0
50                self.rigid.friction = 0
51            else:
52                self.rigid.friction = 1
53
54        # Running animation states
55        if self.grounded:
56            if self.rigid.velocity.x in (-300, 300):
57                if rb.Input.key_pressed("shift") or rb.Input.key_pressed("s"):
58                    self.animation.set_state("sneak", True)
59                else:
60                    self.animation.set_state("run", True)
61            else:
62                if rb.Input.key_pressed("shift") or rb.Input.key_pressed("s"):
63                    self.animation.set_state("crouch", True)
64                else:
65                    self.animation.set_state("idle", True)
66
67        # Reset
68        if rb.Input.key_pressed("r") or self.gameobj.pos.y < -550:
69            self.gameobj.pos = self.initial_pos.clone()
70            self.rigid.stop()
71            self.grounded = False
72            rb.Game.current().camera.pos = rb.Vector(0, 0)
73
74    # define a custom fixed update function
75    def fixed_update(self):
76        # have the camera follow the player
77        current_scene = rb.Game.current()
78        camera_ideal = rb.Math.clamp(
79            self.gameobj.pos.x + rb.Display.res.x / 4,
80            rb.Display.center.x,
81            shared.level1_size - rb.Display.res.x,
82        )
83        current_scene.camera.pos.x = rb.Math.lerp(
84            current_scene.camera.pos.x,
85            camera_ideal,
86            rb.Time.fixed_delta / 0.4,
87        )
88
89    def handle_key_down(self, event: rb.KeyResponse):
90        if event.key == "w" and self.jumps > 0:
91            if self.jumps == 2:
92                self.rigid.velocity.y = 800
93                self.animation.set_state("jump", freeze=2)
94            elif self.jumps == 1:
95                self.rigid.velocity.y = 800
96                self.animation.set_state("somer", True)
97            self.jumps -= 1
level1.py#
 1import shared
 2import rubato as rb
 3
 4scene = rb.Scene("level1", background_color=shared.background_color)
 5
 6# create the ground
 7ground = rb.GameObject().add(
 8    ground_rect := rb.Rectangle(
 9        width=1270,
10        height=50,
11        color=shared.platform_color,
12        tag="ground",
13    )
14)
15ground_rect.bottom_left = rb.Display.bottom_left
16
17end_location = rb.Vector(rb.Display.left + shared.level1_size - 128, 450)
18
19# create platforms
20platforms = [
21    rb.Rectangle(
22        150,
23        40,
24        offset=rb.Vector(-650, -200),
25    ),
26    rb.Rectangle(
27        150,
28        40,
29        offset=rb.Vector(500, 40),
30    ),
31    rb.Rectangle(
32        150,
33        40,
34        offset=rb.Vector(800, 200),
35    ),
36    rb.Rectangle(256, 40, offset=end_location - (0, 64 + 20))
37]
38
39for p in platforms:
40    p.tag = "ground"
41    p.color = shared.platform_color
42
43# create pillars
44pillars = [
45    rb.GameObject(pos=rb.Vector(-260)).add(rb.Rectangle(
46        width=100,
47        height=650,
48    )),
49    rb.GameObject(pos=rb.Vector(260)).add(rb.Rectangle(
50        width=100,
51        height=400,
52    )),
53]
54
55for pillar in pillars:
56    r = pillar.get(rb.Rectangle)
57    r.bottom = rb.Display.bottom + 50
58    r.tag = "ground"
59    r.color = shared.platform_color
60
61# program the right boundary
62shared.right.pos = rb.Display.center_left + (shared.level1_size + 25, 0)
63
64scene.add(
65    shared.player,
66    ground,
67    rb.wrap(platforms),
68    *pillars,
69    shared.left,
70    shared.right,
71)
main_menu.py#
 1import rubato as rb
 2
 3scene = rb.Scene("main_menu", background_color=rb.Color.black)  # make a new scene
 4
 5title_font = rb.Font(size=64, styles=rb.Font.BOLD, color=rb.Color.white)
 6title = rb.Text(text="PLATFORMER DEMO!", font=title_font)
 7
 8play_button = rb.GameObject(pos=(0, -75)).add(
 9    rb.Button(
10        width=300,
11        height=100,
12        onrelease=lambda: rb.Game.set_scene("level1"),
13    ),
14    rb.Text(
15        "PLAY",
16        rb.Font(size=32, color=rb.Color.white),
17    ),
18    r := rb.Raster(
19        width=300,
20        height=100,
21        z_index=-1,
22    ),
23)
24r.fill(color=rb.Color.gray.darker(100))
25
26scene.add(
27    rb.wrap(title, pos=(0, 75)),
28    play_button,
29)

We're also including a version with some more in-depth features that weren't covered in this tutorial, including win detection, advanced animation switching, and a respawn system. Also new scenes, with multiple levels. Noice.

Sneak Peak:

../../../_images/14.png

Here is what that code looks like:

This code has new files.

main.py#
"""
The platformer example tutorial
"""
import rubato as rb

# initialize a new game
rb.init(
    name="Platformer Demo",  # Set a name
    res=(1920, 1080),  # Increase the window resolution
    fullscreen=True,  # Set the window to fullscreen
)

import main_menu
import level1
import level2
import end_menu

rb.Game.set_scene("main_menu")

# begin the game
rb.begin()
level1.py#
from rubato import Tilemap, Display, Vector, Rectangle, wrap, Radio, Events, Game, Time
import shared

scene = shared.DataScene("level1", background_color=shared.background_color)
scene.level_size = int(Display.res.x * 1.2)

end_location = Vector(Display.left + scene.level_size - 128, -416)

tilemap = Tilemap("files/level1.tmx", (8, 8), "ground")
has_won = False


def won():
    global click_listener, has_won
    if not has_won:
        has_won = True
        click_listener = Radio.listen(Events.MOUSEUP, go_to_next)
        scene.add(shared.win_text, shared.win_sub_text)


def go_to_next():
    Game.set_scene("level2")
    click_listener.remove()


def switch():
    global has_won
    shared.player.pos = Display.bottom_left + Vector(50, 160)
    shared.player_comp.initial_pos = shared.player.pos.clone()
    shared.right.pos = Display.center_left + Vector(scene.level_size + 25, 0)
    shared.flag.pos = end_location
    shared.flag.get(Rectangle).on_enter = lambda col_info: won() if col_info.shape_b.tag == "player" else None
    scene.remove(shared.win_text, shared.win_sub_text)
    has_won = False
    shared.start_time = Time.now()
    scene.camera.pos = Vector(0, 0)


scene.on_switch = switch

shared.cloud_generator(scene, 4, True)

scene.add(wrap(tilemap, pos=(192, 0)), shared.player, shared.left, shared.right, shared.flag)
level2.py#
from rubato import Display, Vector, Rectangle, GameObject, Radio, Events, Game, Spritesheet, SimpleTilemap, Surface
from moving_platform import MovingPlatform
import shared

scene = shared.DataScene("level2", background_color=shared.background_color)
scene.level_size = int(Display.res.x * 2)

end_location = Vector(Display.left + scene.level_size - 128, 0)

tileset = Spritesheet("files/cavesofgallet_tiles.png", (8, 8))

platforms = [
    GameObject(pos=Vector(-885, -50)),
    GameObject(pos=Vector(-735, -50)).add(MovingPlatform(100, "r", 400, 2)),
    GameObject(pos=Vector(-185, -450)).add(MovingPlatform(150, "u", 980, 0)),
    GameObject(pos=Vector(0, 300)).add(MovingPlatform(200, "r", 500, 0)),
    GameObject(pos=Vector(1300, 0)).add(MovingPlatform(100, "l", 500, 1)),
    GameObject(pos=Vector(1700, -400)).add(MovingPlatform(1000, "u", 400, 0)),
    GameObject(pos=Vector(2215, 100)),
    GameObject(pos=Vector(2730, -84)).add(
        Rectangle(320, 40, tag="ground"),
        SimpleTilemap(
            [[0, 0, 0, 0, 0, 0, 0, 0]],
            [tileset.get(4, 1)],
            (8, 8),
            scale=(5, 5),
        )
    ),
]

platform = SimpleTilemap([[0, 0, 0, 0]], [tileset.get(4, 1)], (8, 8), scale=(5, 5))
for p in platforms:
    if len(p.get_all(Rectangle)) == 0:
        p.add(r := Rectangle(160, 40))
        if MovingPlatform in p:
            r.tag = "moving_ground"
        else:
            r.tag = "ground"
    p.add(platform.clone())

shared.cloud_generator(scene, 10)

has_won = False


def won():
    global click_listener, has_won
    if not has_won:
        has_won = True
        click_listener = Radio.listen(Events.MOUSEUP, go_to_next)
        scene.add(shared.win_text, shared.win_sub_text)


def go_to_next():
    Game.set_scene("end_menu")
    click_listener.remove()


def switch():
    global has_won
    shared.player.pos = Vector(Display.left + 50, 0)
    shared.player_comp.initial_pos = shared.player.pos.clone()
    shared.right.pos = Display.center_left + Vector(scene.level_size + 25, 0)
    shared.flag.pos = end_location
    shared.flag.get(Rectangle).on_enter = lambda col_info: won() if col_info.shape_b.tag == "player" else None
    scene.remove(shared.win_text, shared.win_sub_text)
    has_won = False
    scene.camera.pos = Vector(0, 0)


scene.on_switch = switch
scene.add(*platforms, shared.player, shared.left, shared.right, shared.flag, shared.vignette)
main_menu.py#
from rubato import Scene, Color, Text, wrap, Font, Game
import shared

scene = Scene("main_menu", background_color=shared.background_color)

title_font = Font(font=shared.font_name, size=58, color=Color.white)
title = Text("Rubato Platformer Demo", title_font)

play_button = shared.smooth_button_generator(
    (0, -75),
    300,
    100,
    "PLAY",
    lambda: Game.set_scene("level1"),
    Color.gray.darker(100),
)

scene.add(wrap(title, pos=(0, 75)), play_button)
end_menu.py#
from rubato import Scene, Color, Game, Display, Time, Text, GameObject
import shared, time

scene = Scene("end_menu", background_color=shared.background_color)

restart_button = shared.smooth_button_generator(
    (0, -75),
    600,
    100,
    "RESTART",
    lambda: Game.set_scene("main_menu"),
    Color.gray.darker(100),
)

screenshot_button = shared.smooth_button_generator(
    (0, -200),
    600,
    100,
    "SAVE SCREENSHOT",
    lambda: Display.save_screenshot((f"platformer time {time.asctime()}").replace(" ", "-").replace(":", "-")),
    Color.gray.darker(100),
)

time_text = GameObject(pos=(0, 100)).add(Text("", shared.white_32))


def on_switch():
    final_time = Time.now() - shared.start_time
    time_text.get(Text).text = f"Final time: {round(final_time, 2)} seconds"


scene.on_switch = on_switch
scene.add(restart_button, screenshot_button, time_text)
shared.py#
import rubato as rb
from random import randint


##### DATA SCENE #####
class DataScene(rb.Scene):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.level_size = 0


from player_controller import PlayerController

##### MISC #####

font_name = "Mozart"
black_32 = rb.Font(font=font_name, size=32)
white_32 = rb.Font(font=font_name, size=32, color=rb.Color.white)
start_time = 0

##### COLORS #####

dirt_color = rb.Color.from_hex("#2c3e50")
platform_color = rb.Color.from_hex("#2c3e50")
wood_color = rb.Color.from_hex("#2c3e50")
background_color = rb.Color.from_hex("#21263f")
win_color = rb.Color.green.darker(75)


##### FOG EFFECT #####
class VignetteScroll(rb.Component):

    def update(self):
        self.gameobj.pos = player.pos


vignette = rb.GameObject(z_index=1000).add(rb.Image("files/vignette/vignette.png"), VignetteScroll())

##### PLAYER #####

# Create the player
player = rb.GameObject(z_index=1)

# Create animation and initialize states
p_animation = rb.Spritesheet.from_folder(
    path="files/dino",
    sprite_size=rb.Vector(24, 24),
    default_state="idle",
)
p_animation.scale = rb.Vector(4, 4)
p_animation.fps = 10  # The frames will change 10 times a second
player.add(p_animation)  # Add the animation component to the player

player.add(
    # add a hitbox to the player with the collider
    rb.Rectangle(width=40, height=64, tag="player"),
    # add a ground detector
    rb.Rectangle(
        width=34,
        height=2,
        offset=rb.Vector(0, -32),
        trigger=True,
        tag="player_ground_detector",
    ),
    # add a rigidbody to the player
    rb.RigidBody(gravity=rb.Vector(y=rb.Display.res.y * -1.5), pos_correction=1, friction=1),
    # add custom player component
    player_comp := PlayerController(),
)

##### Flag #####

# Create animation for flag
flag_sheet = rb.Spritesheet(
    path="files/flag.png",
    sprite_size=rb.Vector(32, 32),
    grid_size=rb.Vector(6, 1),
)

flag_animation = rb.Animation(scale=rb.Vector(4, 4), fps=6, flipx=True)
flag_animation.add_spritesheet("", flag_sheet, to_coord=flag_sheet.end)

# create the end flag
flag = rb.GameObject()
flag.add(flag_animation)

flag.add(
    rb.Rectangle(
        trigger=True,
        tag="flag",
        width=-flag_animation.anim_frame().size_scaled().x,
        height=flag_animation.anim_frame().size_scaled().y,
    )
)

##### SIDE BOUDARIES #####
left = rb.GameObject(pos=rb.Display.center_left - rb.Vector(25, 0)).add(rb.Rectangle(width=50, height=rb.Display.res.y))
right = rb.GameObject().add(rb.Rectangle(width=50, height=rb.Display.res.y))

##### LEVEL WIN TEXT #####
win_font = rb.Font(font=font_name, size=96, color=win_color)
win_text = rb.GameObject(z_index=10000, ignore_cam=True).add(rb.Text("Level Complete!", win_font, anchor=(0, 0.5)))
win_sub_text = rb.GameObject(
    pos=(0, -100),
    z_index=10000,
    ignore_cam=True,
).add(rb.Text("Click anywhere to move on", white_32))


##### CLOUD #####
class CloudMover(rb.Component):

    def __init__(self):
        super().__init__()
        self.speed = randint(10, 50)

    def update(self):
        if isinstance(scene := rb.Game.current(), DataScene):
            if self.gameobj.pos.x < -1210:  # -960 - 250
                self.gameobj.pos.x = scene.level_size - 710  # -960 + 250

        self.gameobj.pos += rb.Vector(-self.speed, 0) * rb.Time.delta_time

    def clone(self):
        return CloudMover()


cloud_template = rb.GameObject(z_index=-1).add(rb.Image("files/cloud.png", scale=rb.Vector(10, 10)), CloudMover())
cloud_template.get(rb.Image).set_alpha(170)


def cloud_generator(scene: DataScene, num_clouds: int, top_only: bool = False):
    half_width = int(rb.Display.res.x / 2)
    half_height = int(rb.Display.res.y / 2)

    for _ in range(num_clouds):
        rand_pos = rb.Vector(
            randint(-half_width, scene.level_size - half_width),
            randint(0 if top_only else -half_height, half_height),
        )

        cloud = cloud_template.clone()
        cloud.pos = rand_pos

        scene.add(cloud)


##### NICE BUTTON #####
def smooth_button_generator(pos, w, h, text, onrelease, color):
    t = rb.Text(text, white_32.clone())
    r = rb.Raster(w, h, z_index=-1)
    r.fill(color)

    b = rb.Button(
        w,
        h,
        onhover=lambda: rb.Time.recurrent_call(increase_font_size, 0.003),
        onexit=lambda: rb.Time.recurrent_call(decrease_font_size, 0.003),
        onrelease=onrelease,
    )

    font_changing: rb.RecurrentTask | None = None

    def increase_font_size(task: rb.RecurrentTask):
        nonlocal font_changing
        if font_changing is not None and font_changing != task:
            font_changing.stop()
        t.font_object.size += 1

        if t.font_object.size >= 64:
            task.stop()
            font_changing = None
            t.font_object.size = 64
        else:
            font_changing = task

    def decrease_font_size(task: rb.RecurrentTask):
        nonlocal font_changing
        if font_changing is not None and font_changing != task:
            font_changing.stop()
        t.font_object.size -= 1
        if t.font_object.size <= 32:
            task.stop()
            font_changing = None
            t.font_object.size = 32
        else:
            font_changing = task

    return rb.GameObject(pos=pos).add(b, t, r)
player_controller.py#
from rubato import Component, Animation, RigidBody, Rectangle, Manifold, Radio, Events, KeyResponse, JoyButtonResponse \
    , Input, Math, Display, Game, Time, Vector
from shared import DataScene


class PlayerController(Component):

    def setup(self):
        self.initial_pos = self.gameobj.pos.clone()

        self.animation: Animation = self.gameobj.get(Animation)
        self.rigid: RigidBody = self.gameobj.get(RigidBody)

        rects = self.gameobj.get_all(Rectangle)
        self.ground_detector: Rectangle = [r for r in rects if r.tag == "player_ground_detector"][0]
        self.ground_detector.on_collide = self.ground_detect
        self.ground_detector.on_exit = self.ground_exit

        self.grounded = False  # tracks the ground state
        self.jumps = 0  # tracks the number of jumps the player has left

        Radio.listen(Events.KEYDOWN, self.handle_key_down)
        Radio.listen(Events.JOYBUTTONDOWN, self.handle_controller_button)

    def ground_detect(self, col_info: Manifold):
        if "ground" in col_info.shape_b.tag and self.rigid.velocity.y >= 0:
            if not self.grounded:
                self.grounded = True
                self.jumps = 2
                self.animation.set_state("idle", True)

    def ground_exit(self, col_info: Manifold):
        if "ground" in col_info.shape_b.tag:
            self.grounded = False

    def handle_key_down(self, event: KeyResponse):
        if event.key == "w" and self.jumps > 0:
            self.grounded = False
            if self.jumps == 2:
                self.rigid.velocity.y = 800
                self.animation.set_state("jump", freeze=2)
            elif self.jumps == 1:
                self.rigid.velocity.y = 800
                self.animation.set_state("somer", True)
            self.jumps -= 1

    def handle_controller_button(self, event: JoyButtonResponse):
        if event.button == 0:  # xbox a button / sony x button
            self.handle_key_down(KeyResponse(event.timestamp, "w", "", 0, 0))

    def update(self):
        move_axis = Input.controller_axis(0, 0) if Input.controllers() else 0
        centered = Input.axis_centered(move_axis)
        # Movement
        if Input.key_pressed("a") or (move_axis < 0 and not centered):
            self.rigid.velocity.x = -300
            self.animation.flipx = True
        elif Input.key_pressed("d") or (move_axis > 0 and not centered):
            self.rigid.velocity.x = 300
            self.animation.flipx = False
        else:
            if not self.grounded:
                self.rigid.velocity.x = 0
                self.rigid.friction = 0
            else:
                self.rigid.friction = 1

        # Running animation states
        if self.grounded:
            if self.rigid.velocity.x in (-300, 300):
                if Input.key_pressed("shift") or Input.key_pressed("s"):
                    self.animation.set_state("sneak", True)
                else:
                    self.animation.set_state("run", True)
            else:
                if Input.key_pressed("shift") or Input.key_pressed("s"):
                    self.animation.set_state("crouch", True)
                else:
                    self.animation.set_state("idle", True)

        # Reset
        if Input.key_pressed("r") or (
            Input.controller_button(0, 6) if Input.controllers() else False
        ) or self.gameobj.pos.y < -550:
            self.animation.set_state("idle", True)
            self.gameobj.pos = self.initial_pos.clone()
            self.rigid.stop()
            self.grounded = False
            Game.current().camera.pos = Vector(0, 0)

    # define a custom fixed update function
    def fixed_update(self):
        # have the camera follow the player
        current_scene = Game.current()
        if isinstance(current_scene, DataScene):
            camera_ideal = Math.clamp(
                self.gameobj.pos.x + Display.res.x / 4, Display.center.x, current_scene.level_size - Display.res.x
            )
            current_scene.camera.pos.x = Math.lerp(current_scene.camera.pos.x, camera_ideal, Time.fixed_delta / 0.4)
moving_platform.py#
from rubato import Component, Time, Vector, Rectangle, Manifold


class MovingPlatform(Component):

    def __init__(self, speed, direction, bound, pause=0):
        super().__init__()
        self.speed = speed
        self.direction = direction
        if direction == "r":
            self.direction_vect = Vector(1, 0)
        elif direction == "l":
            self.direction_vect = Vector(-1, 0)
        elif direction == "u":
            self.direction_vect = Vector(0, 1)
        elif direction == "d":
            self.direction_vect = Vector(0, -1)
        self.bound = bound
        self.pause = pause
        self.pause_counter = 0

    def setup(self):
        self.initial_pos = self.gameobj.pos.clone()
        self.hitbox = self.gameobj.get(Rectangle)
        self.hitbox.on_collide = self.collision_detect

    def collision_detect(self, col_info: Manifold):
        if col_info.shape_b.tag == "player" and self.pause_counter <= 0:
            col_info.shape_b.gameobj.pos.x += self.direction_vect.x * self.speed * Time.fixed_delta

    def fixed_update(self):
        if self.pause_counter > 0:
            self.pause_counter -= Time.fixed_delta
            return

        self.gameobj.pos += self.direction_vect * self.speed * Time.fixed_delta

        self.old_dir_vect = self.direction_vect.clone()
        if self.direction == "r":
            if self.gameobj.pos.x > self.initial_pos.x + self.bound:
                self.direction_vect = Vector(-1, 0)
            if self.gameobj.pos.x < self.initial_pos.x:
                self.direction_vect = Vector(1, 0)
        elif self.direction == "l":
            if self.gameobj.pos.x < self.initial_pos.x - self.bound:
                self.direction_vect = Vector(1, 0)
            if self.gameobj.pos.x > self.initial_pos.x:
                self.direction_vect = Vector(-1, 0)
        elif self.direction == "u":
            if self.gameobj.pos.y > self.initial_pos.y + self.bound:
                self.direction_vect = Vector(0, -1)
            if self.gameobj.pos.y < self.initial_pos.y:
                self.direction_vect = Vector(0, 1)
        elif self.direction == "d":
            if self.gameobj.pos.y < self.initial_pos.y - self.bound:
                self.direction_vect = Vector(0, 1)
            if self.gameobj.pos.y > self.initial_pos.y:
                self.direction_vect = Vector(0, -1)

        if self.old_dir_vect != self.direction_vect:
            self.pause_counter = self.pause

We hope this tutorial gave enough detail as to the basics of rubato to let you make your own games and simulations! If you have questions or feedback, please feel free to contact us on our Discord server or by sending us an email!