Collect coins
Coins that bob in place, and when the player touches one: it adds its value to the game’s count,
plays a sound, leaves a sparkle behind and disappears. A label shows the count. This is the demo
project’s forest coin (scripts/coin.odin).
Three pieces:
- GameState, a global script: one copy for the whole game, so the count survives scene changes;
- Coin, on an Area (a physics body that reports what comes in);
- CoinLabel, on a Label that shows the count.
The scene
Section titled “The scene”- The coin, as a component so you can place many: Add Node > Area
named Coin, with a Sprite named Sprite (the coin’s image) and a CollisionShape
(Circle) under it. Right-click Coin > Save as Component:
components/coin.component.xml. - Optional: a sparkle, a one-shot ParticleEmitter saved as
components/sparkle.component.xml(see Spawn bullets and effects for one that removes itself). - Place coins: drag the component from the browser’s Components folder into the scene, as many as you like. Give the one on the hardest ledge a Value of 5 in the inspector.
- The player is a CharacterMover (the platformer character, for example): coins react to movers only.
- A Label for the count, with the
CoinLabelscript. If the scene has a camera, put it on a screen layer (UI and the camera). - Import
coin.ogg. Make theGameStatescript with Kind: Global script, which adds it to Project Settings > Globals.
The scripts
Section titled “The scripts”package scripts
import e "engine:."
/*GameState: a global script (Project Settings > Globals). One for the whole game, from start toquit, across scenes. Any script reaches it with e.Global(GameState).*/GameState :: struct { using script: e.Script, coins: int,}
@(init)register_GameState :: proc "contextless" () { e.Script_Register(GameState, { // A new level starts the count again sceneChanged = proc(self: ^GameState, scene: ^e.Scene) { self.coins = 0 }, })}package scripts
import "core:math"import e "engine:."import sdl "vendor:sdl3"
/*Coin: on an Area. Bobs its "Sprite" child up and down; when a CharacterMover comes in, adds`value` to the game's coins, plays a sound, leaves a sparkle and removes itself.*/Coin :: struct { using script: e.Script, value: int `export:"default=1 min=1"`, // What's left where it was taken (optional) sparkle: e.ComponentAsset `export:""`, sprite: ^e.Node, time: f32, // Taken already: an Area can report several bodies in one frame taken: bool,}
@(init)register_Coin :: proc "contextless" () { e.Script_Register(Coin, { ready = proc(self: ^Coin) { self.sprite = e.Node_FindChild(self.node, "Sprite") // Worth more: drawn bigger if self.sprite != nil && self.value > 1 do self.sprite.scale = {1.5, 1.5} // Not all bobbing in step self.time = self.node.position.x * 0.05 }, update = proc(self: ^Coin, _: []sdl.Event, deltaTime: f64) { self.time += f32(deltaTime) if self.sprite != nil do self.sprite.position.y = math.sin(self.time * 4) * 1.5 }, // On an Area: a body came inside it collisionEnter = proc(self: ^Coin, hit: ^e.Collision) { if self.taken || !e.Node_Is(hit.other, e.CharacterMover) do return self.taken = true if state := e.Global(GameState); state != nil do state.coins += self.value e.Game_PlaySound(self.game, SOUND_COIN) if self.sparkle.path != "" { e.Scene_AddComponent(self.scene, self.sparkle, position = e.Node_GetWorldPosition(self.node)) } // Gone at the start of the next frame (with its children) e.Scene_RemoveNode(self.scene, self.node.id) }, })}package scripts
import "core:fmt"import "core:strings"import e "engine:."import sdl "vendor:sdl3"
/*CoinLabel: on a Label. Shows the game's coin count, rewriting the text only when it changes.*/CoinLabel :: struct { using script: e.Script, shown: int,}
@(init)register_CoinLabel :: proc "contextless" () { e.Script_Register(CoinLabel, { ready = proc(self: ^CoinLabel) { self.shown = -1 }, update = proc(self: ^CoinLabel, _: []sdl.Event, _: f64) { state := e.Global(GameState) if state == nil || state.coins == self.shown || !e.Node_Is(self.node, e.Label) do return self.shown = state.coins // A label's text must outlive the frame: keep it in the node's own memory label := cast(^e.Label)self.node label.text = strings.clone(fmt.tprintf("Coins: %d", state.coins), e.Node_GetAllocator(self.node)) }, })}The generated constant for the sound:
package scripts
import e "engine:."
SOUND_COIN :: e.SoundAsset{"assets/coin.ogg"}COMPONENT_COIN :: e.ComponentAsset{"components/coin.component.xml"}How it works
Section titled “How it works”collisionEnteron an Area runs when a body comes inside it;hit.otheris that body.e.Node_Is(hit.other, e.CharacterMover)ignores anything that isn’t the player (a rock rolling through). See Collision events.takenguards against counting twice: the coin is removed at the start of the next frame, and two bodies could touch it in the same one.e.Global(GameState)returns the game’s one GameState, ornilwhen the project has none in Project Settings > Globals. Checking fornilkeeps a coin from crashing a test scene.sceneChangedis a hook only globals get: the game moved to another scene. Here every level starts at zero; drop it to keep coins across levels.e.Scene_AddComponentputs a copy of a component in the scene. Without a parent its position is in the world, so the coin passes its world position.- The label keeps its text in the node’s own memory (
e.Node_GetAllocator), freed with the node. Afmt.tprintfstring lasts only one frame. Writing only on change keeps that memory from growing.
Try it
Section titled “Try it”-
Coins from code, three in a row above a node:
// Given: self: ^Coinfor i in 0 ..< 3 {e.Scene_AddComponent(self.scene, COMPONENT_COIN, parent = self.node, position = {f32(i) * 24, -40})} -
Make the pickup pop instead of vanishing: see Juice with tweens.
-
Save the best total across runs: Save and load progress.
-
Announce the pickup as an event other scripts can listen to in the inspector (Events of your own).
