5. Pickups
Pip can walk, but it has nothing to do. In this chapter, you put five pickups on the ground, and Pip collects each one that it touches. You use:
- Colliders, the shapes that the host can test,
- a
ContactsOfQuery, which asks which Colliders touch Pip, - the Contacts, which the next
stepgets.

rocco has no physics. It moves nothing by itself. Instead, your game gives the host shapes and questions, and the host gives back the answers. Your game decides what the answers mean. Collisions and Queries explains this in more detail.
This chapter starts from your app from
4. Lights, a sun and its shadow. The full app is
examples/tutorial/05-pickups/main.roc.
To see every change, compare the two chapter apps:
git diff --no-index examples/tutorial/04-lights/main.roc examples/tutorial/05-pickups/main.rocpip.glb stays the same, and so does the header:
app [init, launch, loaded, step, view] { cam: "https://github.com/Pixelsprout/rocco-engine/releases/download/v0.6.0/camera-QiJqL4WFPQGUbBCMZ5bzjdLHhgRz7BiUw1ngwxWwHtP.tar.zst", linalg: "https://github.com/Pixelsprout/rocco-engine/releases/download/v0.6.0/linalg-4N62Pw77Sky6ba8RiBfvULjHuAQeQiqHbUor7qSeor7F.tar.zst", mesh: "https://github.com/Pixelsprout/rocco-engine/releases/download/v0.6.0/mesh-5Fmg6uFHADmSBmZxNWDg25VStK6mU7YqhkWEXHjMEGNP.tar.zst", pf: platform "https://github.com/Pixelsprout/rocco-engine/releases/download/v0.6.0/platform-2gvPTPztQ7D8yQsMvRTD4rBKS8VmSwMCPZ432eWzNNtJ.tar.zst", roc: "nightly-2026-10-11-7d256b9" }The imports
Add Contact to the exposed types, and import Collider and Query:
import cam.Camera as Cam
import linalg.Vec3 as V
import linalg.Quat
import mesh.Mesh as Mesh
import pf.Vocabulary exposing [Command, Contact, Manifest, Vec3]
import pf.Collider
import pf.Config
import pf.Draw
import pf.Input
import pf.Key
import pf.Launch
import pf.Light
import pf.Query
import pf.QueryResults
import pf.SceneThe Model
A pickup needs a position and an id. Add a Pickup type, and keep the
pickups that are left in the Model:
# id is the draw id of the pickup and of its Collider.
Pickup : { id : U64, pos : Vec3 }
# pip is 0, the fallback mesh, until loaded gives its id.
# yaw turns Pip about +y. At 0, Pip faces +z.
Model : { cube : U32, sphere : U32, pip : U32, pos : Vec3, yaw : F32, pickups : List(Pickup) }The pickups are spheres, so the Model now holds two primitive meshes.
Rename ground to cube, to name each mesh by its shape, and add
sphere. Change model.ground to model.cube in view too.
Ids
So far, view gave the ground id 1 and Pip id 2. Give those ids names,
because step and view both need Pip’s id now:
ground_id : U64
ground_id = 1
# Pip's id is lower than the id of every other Collider, so Pip is always `a`
# in a Contact.
pip_id : U64
pip_id = 2The pickups get the ids 10 to 14. Each pickup’s Draw and its Collider use the same id, so a Contact names the pickup.
init
Put the five pickups in the first Model:
start_pickups : List(Pickup)
start_pickups = [
{ id: 10, pos: { x: -2.0, y: 0.45, z: -1.6 } },
{ id: 11, pos: { x: 2.1, y: 0.45, z: -1.9 } },
{ id: 12, pos: { x: 0.5, y: 0.45, z: -1.0 } },
{ id: 13, pos: { x: -1.7, y: 0.45, z: 1.6 } },
{ id: 14, pos: { x: 1.8, y: 0.45, z: 1.9 } },
]
init : Vocabulary.Config -> { model : Model, commands : List(Command) }
init = |_config| {
model = { cube: Mesh.primitive(Cube), sphere: Mesh.primitive(Sphere), pip: 0, pos: V.zero, yaw: 0.0, pickups: start_pickups }
{ model, commands: [Load({ pack: "pip", assets: ["pip"] })] }
}Each pickup floats at a height of 0.45, about at Pip’s waist.
Colliders and the Query
view now adds a Collider for Pip and for each pickup, and one Query:
pip_half : Vec3
pip_half = { x: 0.3, y: 0.55, z: 0.3 }
pickup_radius : F32
pickup_radius = 0.25
view : Model -> Vocabulary.Scene
view = |model| {
ground = { ..Draw.default, id: ground_id, mesh: model.cube, pos: { x: 0.0, y: -0.1, z: 0.0 }, scale: { x: 6.0, y: 0.2, z: 6.0 }, tint: { r: 0.12, g: 0.25, b: 0.1 } }
pip = { ..Draw.default, id: pip_id, mesh: model.pip, pos: model.pos, rot: Quat.from_yaw(model.yaw) }
pickups = model.pickups.map(|p| Draw(pickup(model.sphere, p)))
camera = Cam.look_at({ x: 0.0, y: 3.6, z: 4.8 }, { x: 0.0, y: 0.0, z: 0.3 })
# The box's centre is half its height up, so it stands on Pip's feet.
pip_box = Collider.box(pip_id, { ..model.pos, y: pip_half.y }, Quat.from_yaw(model.yaw), pip_half)
colliders = [Collider(pip_box)].concat(model.pickups.map(|p| Collider(Collider.sphere(p.id, p.pos, pickup_radius))))
queries = [Query.contacts_of(pip_id)]
nodes = [Light(sky_light), Light(sunlight), Draw(ground), Draw(pip)].concat(pickups).concat(colliders)
{ ..Scene.empty, camera, nodes, queries }
}
# The sphere mesh is 1 unit wide.
pickup : U32, Pickup -> Vocabulary.Draw
pickup = |mesh, p| {
size = 2.0 * pickup_radius
{ ..Draw.default, id: p.id, mesh, pos: p.pos, scale: { x: size, y: size, z: size }, tint: { r: 1.0, g: 0.62, b: 0.08 } }
}Collider.boxgives Pip a box. Its centre is half its height above Pip’s feet, so the box stands on the ground.Quat.from_yawturns the box with Pip, as it turns Pip’s Draw.Collider.spheregives each pickup a sphere, the same size as its Draw.- The ground has no Collider, so it touches nothing.
Query.contacts_of(pip_id)asks the host for each Collider that overlaps Pip’s Collider.- Each Collider goes in
nodeswith theCollidertag, next to the lights and the Draws. Each pickup’s Draw takes theDrawtag in themap.
The shape of a Collider doesn’t need to match the mesh. A box about Pip’s size is close enough for touching pickups.
Collect in step
The host answers the Query of each Scene, and the next step gets the
answer in its QueryResults. So the Contacts are always one step old.
Each Contact names two Colliders, a and b, and a always has the
lower id. Pip has the lowest id of all the Colliders, so Pip is always a
and the other Collider is b:
step : Model, Vocabulary.Input, Vocabulary.QueryResults, F32 -> { model : Model, commands : List(Command) }
step = |model, input, results, dt| { model: collect(model, results.contacts) |> walk(input, dt), commands: [] }
# The ids of the Colliders that Pip touched in the previous step.
touched : List(Contact) -> List(U64)
touched = |contacts| List.keep_if(contacts, |c| c.a == pip_id).map(|c| c.b)
collect : Model, List(Contact) -> Model
collect = |model, contacts| {
ids = touched(contacts)
{ ..model, pickups: List.drop_if(model.pickups, |p| List.contains(ids, p.id)) }
}step collects first, then walks, so Pip collects the pickups that it
touched where it was. walk stays the same as in chapter 3.
If you give a new Collider an id lower than pip_id, Pip becomes b in
its Contacts. Then touched misses it. Keep Pip’s id the lowest, or check
both sides of each Contact.
Test the pickups
A test makes the Contacts by hand, so it needs no host. Add these tests below the tests from chapter 3:
contact_with : U64 -> Vocabulary.QueryResults
contact_with = |id| { ..QueryResults.empty, contacts: [{ a: pip_id, b: id, normal: { x: 0.0, y: 0.0, z: -1.0 }, depth: 0.1 }] }
# A Contact with a pickup collects it, and only it.
expect {
after = step(start, Input.empty, contact_with(12), 0.1).model
after.pickups.map(|p| p.id) == [10, 11, 13, 14]
}
# With no Contact, every pickup stays.
expect step(start, Input.empty, QueryResults.empty, 0.1).model.pickups == start_pickups
# The Scene asks for Pip's Contacts, and each pickup has a Collider.
expect {
scene = view(start)
has_collider = |p| List.any(
scene.nodes,
|node| match node {
Collider(c) => c.id == p.id
_ => Bool.False
},
)
scene.queries == [Query.contacts_of(pip_id)] and List.all(start_pickups, has_collider)
}The normal and depth of the test Contact don’t matter here, because
collecting reads only the ids. Run the tests:
roc test main.rocRun the game
In pip/, run:
roc main.rocWalk Pip into a pickup. The pickup disappears.
Next, read 6. The door and the win. A door opens when every pickup is collected, and walking through it wins the game.