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). A game
declares its own and lets players change them: see Controls.
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
Up to four gamepads are read at once, numbered from 0. Each is a player: pad: picks whose
actions, axes or buttons to read, and leaving it out reads the first.
Input.pads # [0, 1]: the gamepads plugged in
Input.action_pressed?(:jump, pad: 1) # the second player jumped
Input.axis(:horizontal, pad: 1) # the second player's stick
Input.gamepad_down?(:start, pad: 1)
The keyboard belongs to the first player (pad 0). The other players start with the gamepad's bindings only, unless a player binds keys of their own (two players sharing one keyboard).
Gamepads cannot rumble: the window library the engine uses on desktops has no vibration, and neither does Android's.
Controls¶
A game names what its players do (actions, which are on or off, and axes, from -1 to 1) and says
what triggers them. Scripts then read the names, never the keys, so players can change the keys.
The built-in :jump, :fire, :run, :horizontal and :vertical are always there; a game adds
its own, or binds one of those anew, in the input section of its game.json:
{
"input": {
"actions": {
"dash": ["key:e", "pad:b"],
"jump": ["key:space", "key:w", "pad:a"]
},
"axes": {
"steer": {"negative": ["key:a", "pad:dpad_left"], "positive": ["key:d", "pad:dpad_right"], "pad_axis": "left_x"},
"climb": {"pad_axis": "right_y", "invert": true}
}
}
}
A binding is key: with a key's name (key:space, key:left_shift, key:7) or pad: with a
gamepad button's (pad:a, pad:dpad_up). An axis' pad_axis is a stick that moves it too, by as
much as it is pushed; invert runs that stick the other way (up is negative on a stick). A
binding the engine does not know is left out with a warning, and the game plays on. Scripts can
declare names too, before using them. There, a binding may also be a Symbol (a key) or
{keys: [...], buttons: [...]}, mixed as one likes:
Input.define_action(:dash, keys: [:e], buttons: [:b]) # or define_action(:dash, :e, "pad:b")
Input.define_axis(:steer, negative: [:a, "pad:dpad_left"], positive: {keys: [:d], buttons: [:dpad_right]},
pad_axis: :left_x)
Input.actions # [:dash, :fire, :jump, :run]
Input.action_released?(:dash) # true on the frame the last of its keys and buttons lets go
Declaring a name again binds it anew; declaring it as the other kind (an axis that was an action) also takes away what players had bound to the old one.
Letting players rebind¶
Each player (a gamepad's number, pad:, 0 when left out or nil) has bindings of their own:
Input.bindings(:jump) # ["key:space", "pad:a"]
Input.bindings(:horizontal) # {negative: ["key:a", "key:left"], positive: [...]}
Input.bind(:jump, key: :k) # Space and K both jump now
Input.bind(:jump, key: :j, button: :y) # both given: both bound (unbind takes both away too)
Input.unbind(:jump, key: :space) # only K
Input.unbind(:jump) # nothing: every key and button taken away
Input.bind(:fire, button: :y, pad: 1) # the second player's Y fires
Input.bind(:horizontal, key: :j, side: :negative) # an axis says which way the key pushes it
Input.reset_bindings(:jump) # back to the game's, for every player
Input.reset_bindings(pad: 1) # all of the second player's, back to the game's
What players bind is kept between runs in the user's RedGem folder, under bindings/, named as
the game's save is (by-id/<game id>.json, or after the game's folder for a game with no id).
It is written at the end of the frame in which it changed, once however many calls changed it,
and not at all when it ends up as the file already says. A player binds an axis' keys and
buttons only: its stick, and whether the stick is inverted, are always the game's, so a game's
"invert Y" option (declaring the axis again with invert:) reaches every player.
One key may be bound to several actions, and every action it is bound to sees it: Enter can both
:fire in the game and :confirm in a menu. A settings screen that wants each key to do one
thing checks Input.bindings of the other actions before binding it.
A "press a key" screen asks for the next key or button pressed. The block gets the binding as
text, and the gamepad's number (nil for a key); a key already held when it asks does not count.
Escape, or a gamepad's B or Back, calls the capture off instead: the block gets nil for the
binding, so the player can back out (cancel: lists other bindings for that, cancel: [] none).
Whatever went down on the frame a capture ends is the capture's: no action fires and no menu opens
with it.
Input.capture_next do |binding, pad|
next if binding.nil? # called off: jump stays as it was
Input.unbind(:jump, pad: pad)
Input.bind(:jump, binding, pad: pad)
end
Input.capture_next(cancel: ["key:backspace"]) { |binding, pad| ... } # Escape can be bound
Input.capturing? # still waiting
Input.capturing_for?(block) # still waiting to call that block (not once another capture took its place)
Input.cancel_capture # stop waiting (the block is never called)
Input.bindings_changes # a count that goes up whenever actions, axes or bindings may have changed
Sticks and triggers are not caught, only keys and buttons. Input.axis_stick(:horizontal) says
which stick moves an axis (:left_x, or nil): it is the game's, the same for every player.
The controls page¶
GameMenu's settings screen has a Controls button that opens a ready-made controls page (the
:controls screen). At its top, a choice of the player whose bindings it shows (1 to 4: the first
also has the keyboard). Under it, a row for each action and one for each side of each axis, every
one the game declared, in two cells: the player's keys, and their gamepad buttons (an axis' stick
shows among them, though no player rebinds it). Up and down move between the rows, left and
right between a row's two cells.
Enter, a gamepad's A or a click on a cell (which also moves the focus there) waits for the next
key (the keys' cell) or button (the buttons' cell): that one takes the place of everything the cell
had, and the row's other cell keeps its own. A button pressed for the keys' cell (or a key for the
buttons'), or a button of another player's gamepad, is not for it, and it waits on; any keyboard
key is taken for any player. Escape or a gamepad's Back calls the wait off (and B too, for a keys' cell), so those
are never bound there.
A key bound to two rows is allowed, as above, and both cells show it in orange with a !. "Reset to
defaults" puts all of the shown player's bindings back to the game's; Back (or Esc) goes back to the
settings, on their Controls button (the settings were kept on the way in). What players bind is
kept for the next runs as it changes. A game adds options to both screens as to any other
(add_option): they come after the engine's buttons unless an index: says otherwise.
A row is named by the game's translation files, as game.controls.action_<name> (an axis' sides
as game.controls.action_<name>_negative and _positive); without one, its name is made readable (:air_dash
is "Air dash", the sides of :steer "Steer -" and "Steer +"). The engine names its own (:jump is
"Jump", the negative side of :horizontal "Move left") and the keys and buttons ("Space", "Left
Shift", "A button", "Left stick X"), as game.controls.* texts a game may change too.
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.
Forces push for one physics step, so a steady push goes in fixed_update:
def fixed_update(_dt)
rigidbody.add_force(Vector3.up * 30.0) # newtons: a hovering crate
rigidbody.add_force(transform.forward * 5.0, at: transform.position + Vector3.up) # also tips it
rigidbody.add_torque(Vector3.new(0, 2, 0)) # spins it around y
end
rigidbody.mass, gravity_scale (0 floats), angular_velocity (degrees per second) can be read
and changed while the game runs. rigidbody.kinematic = true hands a falling crate to your script
(a crane picks it up); rigidbody.dynamic = true drops it again, at rest. rigidbody.sleep keeps
a body still until something bumps it or wake is called.
Doors, ropes and chains: joints¶
A joint holds two bodies together, or one to the world (give nil as the other). Points and axes
are in world space.
A door on a hinge that pushes open up to 110 degrees, and that its motor pulls shut (back to the 0 degrees it started at, its other limit):
class Door < Behaviour
def start
pin = transform.position + Vector3.left * 0.5 # its left edge
@hinge = Physics.joint(:hinge, game_object, nil, anchor: pin, axis: Vector3.up, limits: [0, 110])
end
def close = @hinge.motor(-60, 50) # degrees per second, newton meters
def let_go = @hinge.motor(0, 0)
end
A rope of distance joints: each link may come closer to the one above it, never farther.
hook = Vector3.new(0, 8, 0)
above = nil
links.each do |link| # dynamic spheres, top to bottom
Physics.joint(:distance, link, above, other_anchor: above ? nil : hook)
above = link
end
spring: 2, damping: 0.3 makes it a bungee. Other kinds: :fixed glues two bodies as they are,
:slider lets one slide along an axis (limits: in meters), :point lets it turn every way (a
ball and socket). break_force: 500 breaks a joint pulled harder than 500 newtons, or twisted
harder than 500 newton meters (joint.broken? then says so); joint.remove lets go; destroying either object removes it.
Collision layers¶
Bullets that fly through pickups, ghosts that walk through walls: give each body a layer and
say in game.json which layers meet. Up to 16 layers; default is always there.
"physics": {
"layers": ["default", "player", "enemy", "bullet", "pickup"],
"collides": {"bullet": ["enemy", "default"], "pickup": ["player"]}
}
A layer with no collides entry meets every layer; two layers meet only when each lets the other
in, so above bullets hit enemies and walls but pass through players, pickups and other bullets,
and pickups only notice players. Put an object on a layer with the layer custom property (in
Blender or the Inspector), or from a script:
bullet = Scene.spawn("Bullet", position: muzzle, rotation: transform.rotation)
bullet.rigidbody.layer = :bullet
bullet.rigidbody.velocity = transform.forward * 40.0
Triggers only notice bodies whose layer meets theirs, and queries look at the layers they are given.
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). Every query also takes
layers: (a layer or a list) and ignore: (an object or a list):
# Everything a laser goes through, nearest first.
Physics.raycast_all(eye, forward, 50.0, layers: [:enemy, :default], ignore: [game_object, gun])
# Would a ball of radius 0.4 get from here to there? (nil: the way is clear)
blocked = Physics.sphere_cast(transform.position, target, 0.4, ignore: game_object)
# An explosion: everything on the enemy layer within 5 m.
Physics.overlap_sphere(transform.position, 5.0, layers: :enemy).each do |hit|
hit[:object].rigidbody&.add_impulse((hit[:point] - transform.position).normalized * 20.0)
end
box_cast, capsule_cast, overlap_box and overlap_capsule work the same way. Hits come
nearest first, each object once, at most 64.
Follow, orbit and shake the camera¶
The camera follows, orbits and shakes on its own once told to: no script of its own has to move it every frame.
A third-person camera follows an object at an offset, smoothed (smoothing is the seconds it
takes to close half the way, the same at any frame rate), looking at it, and comes in front of any
wall that would hide it (a Physics.raycast from the object to the camera):
class Hero < Behaviour
def start
Camera.follow(game_object, offset: Vector3.new(0, 2.5, 5), smoothing: 0.12)
end
end
relative: true takes the offset in the object's own axes (its +Z is behind it), so the camera
swings round as it turns; dead_zone: 0.5 lets it move half a meter before the camera goes after
it; look_at: false leaves the camera's turn alone. It starts in its place; snap: false flies
there from where the camera is. What the object carries (its children) is never taken for a wall.
An orbit viewer looks at an object or a point from around it: the player turns it by dragging with the right mouse button or with the right stick, and zooms with the wheel, within limits:
class Viewer < Behaviour
def start
@orbit = Camera.orbit(Scene.find("Statue"), distance: 8, pitch: 25, distances: 3..15, pitches: -10..70)
end
def update(dt) = @orbit.turn(10 * dt, 0) # a script turns it too: a slow turntable
end
The player turns it also while the game is paused or slowed (Time.unscaled_delta); its yaw stays
within -180..180 and eases the short way round. input: false leaves it to the script alone. Camera.stop_following stops either; an object
followed or orbited that is destroyed is let go, and the camera stays where it was.
A hit shakes the camera. Each shake adds its strength to the camera's trauma (never past 1), which fades out over its duration; what the camera shows moves and turns by the square of it, along smooth noise, on top of a follow or an orbit. The camera object itself never moves, so a script of your own that moves or turns it keeps exactly its own pose:
def on_collision_enter(other)
Camera.shake(0.4, duration: 0.3) if other.tag?(:enemy)
end
A camera with a parent (on a vehicle, a dolly) works too: the helpers place it in the world.
Mark things on the screen¶
Camera.world_to_screen says where a point of the world is on the screen, in the pixels UI
draws in, at any window size and render scale: a health bar over an enemy, an arrow over a coin.
It is nil when the point is behind the camera; a point beside the view comes back off the screen.
class HealthBar < Behaviour
WIDTH = 60
def update(_dt)
x, y = Camera.world_to_screen(transform.position + Vector3.up * 2.2)
return unless x
UI.rect(x: x - WIDTH / 2, y: y, width: WIDTH, height: 8, color: :black)
UI.rect(x: x - WIDTH / 2, y: y, width: WIDTH * @health / 100.0, height: 8, color: :red)
end
end
It sees from where the picture is drawn from, shake included. A behaviour added after the first
Camera.follow (or orbit, or shake) sees the camera where this frame draws it; one that was there
before sees where it was a frame ago. Camera.screen_ray goes the other way.
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].
To show it in the player's language, keep the text in the game's translation files and show it
with I18n.t("hud.coins", coins: @coins, total: @total): see
Texts in several languages.
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, an end screen, and a settings screen (see A settings screen). Up and
down move the focus between its buttons; Enter or a gamepad's A picks the focused one. 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, :over, :settings and :controls. 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 label or a heading may also be a lookup,
-> { I18n.t("menu.credits") }, made each time it is shown (see Texts in several languages). A screen that draws more than buttons is a
subclass of GameMenu::Screen (draw, space_above, opened, closed, typing?, and rows
for widgets of its own that take the focus before its buttons, with focus): the
platformer's multiplayer screen, games/platformer/scripts/multiplayer_screen.rb, is one.
A loading screen¶
Game.on_loading do |delta|
UI.text("Loading #{Game.loading_scene}...", x: 24, y: 24, size: 28)
Game.cancel_scene_load if UI.button("Cancel", x: 24, y: 80, width: 160, height: 48)
end
Game.load_scene_async("level2")
Normal simulation pauses while the file is read; the loading screen, audio and network continue.
Failure or cancellation preserves the current scene. Check Game.loading_error after the load
ends to report a failure or retry. Successful activation removes the old loading callback.
Physics, texture decoding and GPU upload can still cause an activation hitch; see
the API's loading limits. Use Game.load_scene for the existing synchronous switch.
A settings screen¶
GameMenu has one ready, reached from its title and pause screens: window mode, resolution,
render scale, vsync, frame-rate cap, quality and master volume, with a button to its controls page
(see The controls page). A game builds its own with
Settings and the widgets of UI, which the keyboard and a gamepad work through a focus:
def update(_dt)
@focus ||= UI::Focus.new
@focus.update(3) # up and down (arrows, D-pad) move it among three widgets
box = { x: 40, width: 480, height: 44 }
on = UI.toggle("VSync", Settings.vsync, y: 100, focused: @focus.at?(0), **box)
Settings.vsync = on unless on == Settings.vsync
names = %w[Low Medium High Ultra]
index = Settings::QUALITIES.index(Settings.quality) || 3
picked = UI.choice("Quality", names, index, y: 152, focused: @focus.at?(1), **box)
Settings.quality = Settings::QUALITIES[picked] unless picked == index
volume = UI.slider("Volume", Settings.volume, y: 204, focused: @focus.at?(2), **box)
Settings.volume = volume unless volume == Settings.volume
end
Each change shows at once; Settings.save keeps them for the game's next runs. A quality caps
what the level's environment asks for (samples a pixel, temporal anti-aliasing, shadow cascades
and distance, scattered copies and how far they show, the sky picture's size, ambient occlusion,
bloom): :ultra draws the level as it was made, and nothing ever gets more than it asked for.
What the graphics card cannot do is left out whatever the quality.
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 follows this object, from a local exhaust offset. Old puffs stay in the world.
@smoke = Particles.emitter(game_object, rate: 20, offset: [0, 0.5, 1], color: :gray,
direction: Vector3.up, spread: 20, speed: 1.0, life: 1.5,
size: 0.4, gravity: -0.05)
Particles.emitter(object, rate: 24, offset: Vector3.zero, **settings) returns a handle with
active? and stop. It takes the burst settings below except count: rate is particles per
game second, greater than zero and at most 4096. Offset uses the object's full world transform;
direction follows its world rotation. Birth positions are spread along the object's movement
between frames. Movement over 10 meters in one update is treated as a teleport: new particles
start at the destination. Each birth is aged and advanced by the time since its birth within
the frame. Emitted particles move independently in world space, without inheriting the
object's velocity. Time.scale = 0 pauses emission.
Destroying the object or changing scenes ends its emitters; stopping one leaves its existing
particles to finish their lives. Particles.clear removes particles and emitters together. Up to
256 emitters share the existing limit of 4096 live particles. When that limit is full, missed
births are dropped rather than saved for a later burst. Priority passes to the next emitter
that is waiting for space, so a full pool cannot permanently favor the first one. Emitter
handles are never reused.
These are local visual effects, not replicated by NetworkSync. Start matching effects on
each client through gameplay events. A game running without a presenter, including a dedicated
server, returns inactive emitter handles and does not allocate or simulate emitters.
| 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 |
frames, fps |
The image is a sprite sheet of [columns, rows] pictures, read like a page; fps of them a second, round and round, or all of them once over the particle's life without one |
one picture |
spin |
Degrees per second each picture turns | 0 |
lit |
Takes the light of the sun, the sky and the lights near it, as smoke and dust do (glowing ones shine with their own light either way) | false |
soft |
Meters over which it fades where it meets the ground or a wall, instead of cutting a hard line into it | 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).
Over their life¶
size, color and alpha may each be a list of up to 4 values, which every particle goes
through evenly over its life, from the first at its birth to the last at its end. A single size
or alpha does what particles always did: it holds for half the life, then the size shrinks to
0.3 of itself and the alpha fades out. A single color holds.
# A flame that licks (an 8 x 4 sheet at 30 pictures a second), shrinking from yellow to a dark ember.
Particles.burst(torch, count: 1, image: :flame_sheet, frames: [8, 4], fps: 30, glow: true, soft: 0.3,
size: [0.5, 0.6, 0.15], color: [[255, 230, 160], [255, 120, 30], [70, 25, 15]], life: 0.6)
# Smoke that grows as it rises, fades in and out, and darkens in the shade.
Particles.burst(chimney, count: 1, image: :smoke, lit: true, soft: 1.0, size: [0.5, 2.5],
alpha: [0, 120, 90, 0], gravity: -0.1, life: 3.0, spin: 20)
cast_shadow: true includes a particle in the directional sun's shadow cascades; it defaults to
false. Its shadow is an alpha-cutout sprite facing the sun, using its current size, rotation,
alpha and flipbook cell (or the circular dot mask without an image). Fragments whose combined
texture and particle alpha is below 0.5 cast no shadow. Fading can therefore shrink or remove a
shadow abruptly. This is a sprite approximation, not volumetric smoke: overlapping particles do
not accumulate density, and soft: only fades the color pass
against the camera's scene depth. Point lights do not cast particle shadows.
Lit particles are lit at their corners by the sun, the sky and the nearest lights, as a puff lit
on about half of it. lit: true also receives the scene's filtered sun shadows, using the same
shadow cascades as surfaces. Sun shadowing leaves sky and point-light contributions intact.
Unlit particles, including unlit glowing ones, keep their own color in shadows.
Light that flickers and fades¶
An object with a light (a lamp, a fire's glow, made in Blender like any light) can change how bright it shines and in what color while the game runs:
glow = Scene.spawn("FireLight", position: torch)
every(0.05) { glow.light.intensity = 40 + rand * 25 } # a flame's flicker
tween(0.6) { |t| glow.light.intensity = 300 * (1 - t) } # a blast that fades
glow.light.color = [255, 120, 40] # or a name: :orange
intensity is in the units Blender gives the light (watts for a point or spot light, lux for the
sun) and is at least 0; color is three channels from 0 to 255, as a color picker shows them.
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.
Each change is flushed to the disk before it replaces the file, and the save before it is kept
beside it as <name>.json.bak, written the same way. A save found damaged (cut short by a crash
or a bad disk) is moved aside to <name>.json.damaged, never written over, and the backup takes its
place; the log says so, naming both files. When the backup is damaged too, it is moved aside as
well and the game starts from nothing. A save that cannot be read at all (another program holds
it, say) is left alone: the game runs without saving, and the log says why.
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.
Measure frame times¶
Debug.stats_visible = true # also starts profiling; shows the developer panel
Debug.frame_stats[:mean][:update_ms] # average foreground update time, in milliseconds
Debug.frame_stats[:peak][:scene_ms] # largest foreground scene operation in the window
Debug.stats_visible = false # hides the panel while keeping collection active
Debug.profiling = false # stops collection; keeps the last summary
Debug.reset_frame_stats # clears the window and any unfinished measurement
The panel works in the player, editor Play and recordings. Collection is off by default and keeps at most 120 completed frames. Timings use the steady clock, so pausing or slowing the game does not change what is measured. Physics is included in update, and presentation includes any upload, frame cap or synchronization wait: these are inclusive foreground wall times, not GPU timers or a per-function sampling profiler. Leave the panel off for reproducible pictures.
Draw world diagnostics¶
def update(_delta)
origin = transform.position
Debug.line(origin, origin + Vector3.up, color: :green)
Debug.box(origin, Vector3.new(1, 2, 1), color: :yellow, depth_test: false)
end
Lines and axis-aligned wire boxes use world coordinates and are submitted every frame. Box size
is its full nonnegative width, height and depth. They create no objects and are local to the
presenter; a dedicated server validates arguments and returns false without collecting commands.
Calls return true when accepted and false after the shared limit of 4096 commands per frame.
The next frame starts with an empty buffer. Invalid vectors, box bounds or color channels are rejected before reaching the renderer. Use UI.text for a screen-space label.
depth_test: true hides shapes behind opaque geometry; false draws through it. Transparent
surfaces use the renderer's normal depth, so this is an opaque-scene diagnostic. Frames with shapes
use an unjittered camera and restart temporal history; normal temporal sampling resumes when no
shapes are submitted. Editor, scaled and recorded views reuse their color/depth target. Native
windows allocate one cached target on demand; if allocation fails, the game still draws, reports
a warning and omits the shapes. Changing the window size allows another allocation attempt.
Keep separate progress in save slots¶
Save.slot = 'profile-1'
Save[:coins] = 12
Save.slot = 'profile-2'
Save[:coins] # nil: another slot
Save.slot = 'profile-1'
Save[:coins] # 12
Save.slots # sorted String names, including 'default'
Save.slot = 'default' # the original save, with its existing data
Every engine starts on default. Selection lasts across scene changes and is independent for
server rooms, even when their database is shared. get, set, update, keys, delete and
clear use the selected slot; clearing one does not erase the others. An update block writes
back to the store selected at entry, even if the block selects another slot. The block's other
side effects are not rolled back when it raises.
Names contain 1–48 lowercase ASCII letters/digits, hyphens or underscores and start with a letter
or digit. Symbols are accepted on assignment; the getter and list return Strings. Invalid names
raise ArgumentError; a failed selection leaves the previous slot and its data selected. At
most 64 named slots can be opened per engine/run (and per shared local-file backend); listing is
bounded to 4096 names. Unsupported custom backends or exceeded limits raise RuntimeError.
The default file is neither renamed nor migrated. Named files live beside it in
<save-file>.slots/slot-<name>.json and use the same atomic writes, backups and damaged-file
recovery. Existing symbolic links at slot folders/files/backups are refused. Files are cached
by the backend, so returning to an opened slot does not create stale copies of its values.
Local-file stores retain their existing single-threaded, one-process contract; shared server
rooms use SQLite. Named SQLite data has separate tables and the original default table remains
unchanged. Storage operations still run synchronously.
Save.slots includes persisted names plus slots opened during this run. A file slot is only
persisted after a write; the database remembers named slots when they are opened. A supplied
custom SaveStore keeps its original behavior and must implement the optional slot factory to
support named selection. Game tests isolate slot files too: fresh runs clear live data/backups,
restart runs copy them, and damaged evidence is retained rather than copied into another test.
This API stores values; it does not automatically serialize scene objects or migrate game data.
Keeping references to scene objects¶
A loaded object's stable_id is its identity in the scene file, independent of its name or
runtime id. Use Scene.find_by_id(identity) to look it up in the current scene; it accepts a
String or Symbol and returns nil when the original is gone or the identity is unknown.
Save[:selected_object] = {
"scene" => Game.scene_name,
"id" => Scene.find!("Door").stable_id
}
selection = Save[:selected_object]
if selection && selection["scene"] == Game.scene_name
door = Scene.find_by_id(selection["id"])
end
Identities are scoped to one scene: save the scene name with the identity. The raw String has
no # prefix (the command-line selector adds that prefix). Old files receive deterministic
identities on read; a scene must be saved by the editor or command-line tools to persist them
before an external tool reorders its nodes. See scene editing.
Runtime copies have nil identities, including their descendants and copies of copies. A copy
never resolves as its source, even after an object is destroyed or a runtime slot is reused.
An old object reference remains stale after a scene switch; look up the saved identity again.
An object whose in-memory source supplies no identity also returns nil. This lookup does not
serialize transforms, behaviours or spawned instances; object checkpoints are a separate API.
Object checkpoints¶
GameObject.capture_state returns a versioned Hash of the object's local position, quaternion
rotation, scale, tint and visibility. Keep that Hash unchanged in Save, beside the object's
identity. On a fresh run or after loading the matching scene, resolve the identity and restore it:
hero = Scene.find!("Hero")
Save[:hero] = { "id" => hero.stable_id, "state" => hero.capture_state }
# Later, in the same scene (for example its SceneScript#setup):
saved = Save[:hero]
hero = saved && Scene.find_by_id(saved["id"])
hero.restore_state(saved["state"]) if hero
Restoration validates the complete payload and resulting hierarchy before changing anything.
The scene name, source node, original/copy kind, parent source and current physics body must
match. Malformed, incompatible or oversized data raises ArgumentError; a destroyed or pending
object raises RuntimeError. A stale reference never restores a new object that reused its slot.
A hierarchy pending destruction is refused. In-memory sources need explicit scene-file identities.
The pose is local to the current parent. Physical bodies in the moved hierarchy are teleported immediately and stop moving; velocities are not saved. Existing colliders cannot change scale: a checkpoint that would rescale a physical hierarchy is refused. Render-only hierarchies can restore scale. Local components and resulting world components are bounded to one million in magnitude, and at most 4096 objects can move in one restoration.
A checkpoint also works on an explicitly chosen runtime copy of the same source and parent; it cannot silently select a copy or overwrite its original. The game creates any needed copies first. This API neither spawns nor resurrects objects, changes parents, nor restores descendants' individual local poses or appearance. Save each object's checkpoint if those values matter. Animations, behaviours, joints, forces and game time remain active. Health, inventory and other script-owned values must be saved explicitly beside the checkpoint; this is not a whole-game snapshot. Checkpoints follow scene-file identity rules described above.