Gameplay recipes¶
Short answers to "how do I...". Each snippet goes inside a behaviour.
Read the keyboard¶
Input.axis(:horizontal) # -1..1: A/D or left/right arrows
Input.axis(:vertical) # -1..1: S/W or down/up arrows
Input.action_pressed?(:jump) # true on the frame Space goes down
Input.action?(:run) # true while Left Shift is held
Input.down?(:e) # any key: held
Input.pressed?(:e) # any key: went down this frame
Built-in actions: :jump (Space), :fire (Left Ctrl or Enter), :run (Left Shift).
Read a gamepad¶
A game written with axes and actions already works with a gamepad: the left stick moves
:horizontal and :vertical (by as much as it is pushed), and A, X and B are :jump, :fire
and :run. For the rest, ask the gamepad itself:
Input.gamepad? # is one plugged in?
Input.gamepad_axis(:right_x) # -1..1: left_x, left_y, right_x, right_y (left and up are negative)
Input.gamepad_axis(:right_trigger) # 0..1: left_trigger, right_trigger
Input.gamepad_down?(:start) # held; also gamepad_pressed? and gamepad_released?
Buttons are named as on an Xbox pad: :a, :b, :x, :y, :left_bumper, :right_bumper,
:back, :start, :dpad_up, :dpad_down, :dpad_left, :dpad_right, :left_stick,
:right_stick. To walk slowly with a half-pushed stick, use clamped instead of normalized:
direction = Vector3.new(Input.axis(:horizontal), 0, -Input.axis(:vertical)).clamped
Read the mouse¶
Input.mouse_x, Input.mouse_y # pixels from the top-left corner
Input.mouse_dx, Input.mouse_dy # how far it moved since the last frame
Input.mouse_down?(:left) # :left, :right or :middle, held
Input.mouse_pressed?(:left) # went down this frame
Input.mouse_released?(:left) # went up this frame
Input.mouse_wheel # notches turned this frame
To turn a first-person camera with the mouse, hide the pointer and keep it in the window, then use how far it moved:
def start = Input.mouse_locked = true
def update(_dt)
transform.rotate(Vector3.new(0, -Input.mouse_dx * 0.1, 0))
end
To click on things in the world, cast a ray from the camera through the pointer:
if Input.mouse_pressed?(:left)
origin, direction = Camera.screen_ray(Input.mouse_x, Input.mouse_y)
hit = Physics.raycast(origin, direction, 100.0)
hit[:object].destroy if hit
end
Move a character¶
Give the object a character body: set Body in the Inspector's Physics card (objects made in the editor already have one), or use Add Component > Physics body on an object without physics. A character walks on floors, climbs slopes and is stopped by walls.
def update(_dt)
direction = Vector3.new(Input.axis(:horizontal), 0, -Input.axis(:vertical)).normalized
character.move(direction * 6.0) # meters per second
character.jump(9.0) if Input.action_pressed?(:jump) && character.grounded?
end
Push things around¶
A dynamic body falls, rolls and gets pushed. From a script:
rigidbody.add_impulse(Vector3.up * 5.0)
rigidbody.velocity = Vector3.new(0, 0, -10)
A kinematic body is moved by your script (set transform.position) and carries what stands
on it: platforms, lifts, doors.
Drive a car¶
Give the chassis a vehicle body and mark its wheel children with wheel = true.
def fixed_update(_dt)
vehicle.drive(throttle: Input.axis(:vertical), steering: Input.axis(:horizontal),
handbrake: Input.down?(:space) ? 1.0 : 0.0)
end
vehicle.speed is in meters per second, negative in reverse.
Pickups and zones¶
Tick Trigger in the object's Physics card (it stops being solid) and react when something enters:
class Coin < Behaviour
def on_trigger_enter(other)
return unless other.get_component(Player)
Audio.play(:coin)
destroy
end
end
Only moving things (characters, dynamic and kinematic bodies) set triggers off.
Spawn objects¶
Mark an object as a Template in the Inspector (prefab = true). It disappears from the game
and becomes something to copy:
bullet = Scene.spawn("Bullet", position: transform.position, rotation: transform.rotation)
bullet.add_component(Bullet, speed: 30.0)
The copy comes with the template's physics and scripts.
Look ahead with a ray¶
hit = Physics.raycast(transform.position, transform.forward, 20.0, game_object)
Debug.log("I see #{hit[:object].name} #{hit[:distance]} m away") if hit
The last argument is an object the ray ignores (usually the one casting it).
Show text on screen¶
Call it every frame from update:
UI.text("Coins #{@coins}/#{@total}", x: 20, y: 40, size: 28, color: :gold)
Colors: :white, :black, :red, :green, :blue, :yellow, :gold, :orange, :gray, or
[r, g, b].
Menus, buttons and images¶
Everything in UI is drawn the frame you call it, so a menu is just a behaviour that draws
itself every frame. UI.button returns true on the frame it is clicked:
def update(_dt)
UI.rect(x: 0, y: 0, width: UI.width, height: UI.height, color: :black, alpha: 170)
UI.text("My Game", x: UI.width / 2, y: 120, size: 72, align: :center)
UI.image(:logo, x: 40, y: 40, width: 128) # images/logo.png
start_game if UI.button("Play", x: UI.width / 2 - 140, y: 300, width: 280, height: 58)
Game.quit if UI.button("Quit", x: UI.width / 2 - 140, y: 374, width: 280, height: 58)
end
UI.width and UI.height are the size of the screen, so layouts can be centered. Pictures go in
the game's images/ folder (.png or .jpg).
A text field hands back what is in it after this frame's typing, so keep the value and pass it in
again (Input.text is what was typed this frame, for anything else):
@name = UI.text_field(@name, x: 40, y: 200, width: 320, height: 48, max_length: 12)
Pause, and the flow between screens¶
Time.scale = 0 freezes the game: physics stops and behaviours get an update with a delta of
0, so a menu keeps working while everything else stands still. Time.scale = 1 resumes.
The usual flow of a game is made of three things:
- A title screen: a menu that starts open, with the game frozen behind it.
- Pause: the same menu opened by a key.
- Other screens and levels:
Game.load_scene("level2")to move on, andGame.load_scene(Game.scene_name)to restart the current one.
GameMenu is all of this, ready made: a title screen, a pause menu on Esc, P or a gamepad's
Start, and an end screen. Put it on an object from the scene's script, and end the game from
anywhere:
class MainScene < SceneScript
def setup
Camera.main.add_component(GameMenu) # title: "...", start_open: false
end
end
GameMenu.instance.finish("You win!")
Its screens are :title, :pause and :over. A game adds options and screens of its own:
menu = GameMenu.instance
menu.screens[:credits] = GameMenu::Screen.new(heading: "Made by Ana", back: -> { menu.show(:pause) })
menu.screens[:pause].add_option("Credits", index: 1) { menu.show(:credits) }
back is what the pause keys do on that screen. A screen that draws more than buttons is a
subclass of GameMenu::Screen (draw, space_above, opened, closed, typing?): the
platformer's multiplayer screen, games/platformer/scripts/multiplayer_screen.rb, is one.
Play sounds and music¶
Files go in the game's sounds/ folder.
Audio.play(:jump) # sounds/jump.wav (or .ogg, .mp3, .flac)
Audio.play(:hit, volume: 0.5, pitch: 1.2)
Audio.music(:theme, loop: true)
Audio.stop_music
A sense of speed¶
rush = (speed / TOP_SPEED).clamp(0.0, 1.0)
Camera.fov = 52 + 10 * rush # the view widens
Camera.motion_blur = 0.7 * rush # the edges of the picture streak
Audio.music_pitch = 0.8 + 0.7 * rush # the engine's note climbs (Audio.music(:engine) is looping)
Change level¶
Game.load_scene("level2") # scenes/level2.glb; "main" is scene.glb
The switch happens at the end of the frame. Ruby global variables survive it, which is the simple way to carry a score or lives from one level to the next.
Do something later, or over a while¶
Every behaviour has timers. They run on game time, so they wait while the game is paused, and they end with their object.
after(2.0) { destroy } # once, two seconds from now
every(0.5) { Scene.spawn("Bullet", position: muzzle) } # again and again
tween(0.4) { |t| transform.scale = Vector3.one * (1.0 + t) } # every frame, t going from 0 to 1
after and every return a timer you can cancel; cancel_timers calls off all of a behaviour's.
tween starts and ends gently; ease: :linear keeps a steady pace.
Sparks, dust and smoke¶
Particles.burst lets out small dots that fly and fade. They are looks only: nothing collides
with them.
# A coin picked up: gold sparks in every direction.
Particles.burst(transform.position, count: 24, color: :gold, glow: true)
# Smoke from an exhaust: a few gray puffs a second, drifting up in a narrow cone.
every(0.05) do
Particles.burst(exhaust, count: 1, color: :gray, direction: Vector3.up, spread: 20,
speed: 1.0, life: 1.5, size: 0.4, gravity: -0.05)
end
| Setting | Meaning | Default |
|---|---|---|
count |
How many | 12 |
color, alpha |
A color name or [r, g, b]; 0 to 255 |
:white, 255 |
glow |
Shines (sparks, magic) instead of covering what is behind (dust, smoke) | false |
direction, spread |
Where they fly, and how many degrees they may stray: 180 is everywhere | up, 180 |
speed, life, size |
Meters per second, seconds, meters across (each particle varies a little) | 3.0, 0.8, 0.12 |
gravity |
1 falls like everything else, 0 floats, negative rises | 1.0 |
image |
A picture from the game's images/ folder drawn on each particle, tinted by color |
a soft dot |
spin |
Degrees per second each picture turns | 0 |
A white picture with see-through edges works best, since the color tints it:
Particles.burst(at, image: :smoke, color: :gray, alpha: 90, size: 1.2, life: 1.5, gravity: -0.05, spin: 40).
Control animations¶
Every animation in a level plays in a loop from the start. Animation is for the ones that should
not, by the name the action has in Blender:
Animation.stop(:DoorOpen) # in a scene script's setup: starts closed
Animation.play(:DoorOpen, loop: false) # opens once and stays open
Animation.play(:DoorOpen, loop: false, speed: -1.0) # closes
Animation.pause(:Fan) # freezes where it is; play carries on
Animation.playing?(:DoorOpen) # false once it reached its end
Animation.names lists the level's clips; time, duration and seek(name, seconds) are there
for syncing things to a clip. A stopped clip lets go of its objects, so scripts can move them.
Animate one character¶
Animation is for the whole level. A character with several clips (idle, walk, run) picks its own
through animation, and so does each spawned copy of it:
def start = animation.play(:Idle)
def update(_dt)
animation.play(moving? ? :Run : :Idle, fade: 0.2) # blends from one to the other over 0.2 s
end
Playing the clip a character is already on just carries on, so it is fine to call every frame.
loop: false plays once and holds the last pose; speed: and restart: true work as above.
A second clip can be laid over the first:
animation.mix(:Wave) # a clip that only moves the arm: the legs keep walking
animation.mix(:Limp, weight: 0.5) # half of it blended into everything
animation.unmix
animation.follow_scene(fade: 0.3) hands the character back to what the level plays, and
animation.clip is the name of its own clip (nil while it follows the level).
Save progress¶
Save keeps numbers, texts and true/false values between runs, in one file per game:
best = Save.get(:best_time, 999.0) # the second value is what you get when nothing was saved
Save[:best_time] = time if time < best
Save[:name] = "Ana"
Save.delete(:name)
Save.clear # start from nothing
Whole numbers come back as integers (a saved 12.0 is read as 12): call .to_f on a value you
divide.
The file is written on every change, to .config/redgem/saves/by-id/<id>.json in the player's
home folder, where <id> is the game's id from its game.json: the save stays with the game
when its folder is moved or renamed. A game with no id saves to
.config/redgem/saves/<folder>-<digits>.json, with digits made from where its folder is. Tests
get a save file of their own, empty at the start of each test.
Keep score across objects¶
Put a manager behaviour on one object and let the others find it:
class GameManager < Behaviour
attr_reader :coins
def self.instance = Scene.find("GameManager")&.get_component(GameManager)
def start = @coins = 0
def collect_coin = @coins += 1
end
# anywhere else
GameManager.instance.collect_coin
Time¶
Time.elapsed is seconds since the game started, Time.delta the length of the last frame.