Drive a capsule with collide-and-slide instead of a dynamic rigid body. Author a physics actor with is_character_controller, set desired velocity each tick via hi.world().setVelocity, and read grounded state with hi.world().physicsState.
Deep reference: Physics. Prerequisites: First physics, First input action.
Zig, Kawa, or both?
| Approach | Author CCT (scene/editor) | Drive desired velocity / grounded |
|---|---|---|
| Zig-only | Yes | Yes — w.setVelocity / w.physicsState in Zig update |
| Zig + Kawa | Yes | Zig owns locomotion; Kawa can handle on_collision, messages, camera, etc. on the same actor |
| Kawa-only locomotion | Yes (flag on the actor) | Yes — Physics.update(actor, { linear_velocity = wish }) and Physics.state(actor).grounded. Do not use Actor.set_position as a substitute (teleports, skips collide-and-slide) |
Authoring the capsule controller is language-agnostic. Moving it each frame is setVelocity / Physics.update — never a position write.
What you get
| Piece | Role |
|---|---|
| Capsule collider | Shape for the controller (collider_radius + collider_half_height) |
| Kinematic body | Owned by the CCT; not a dynamic solver body |
Desired linear_velocity | World-space intent only (m/s) via setVelocity — never bake platform velocity in |
| Ride | Tenkai adds ground surface velocity while grounded (elevators). Jump inherits one frame automatically |
PhysicsStateView | grounded / hit_ceiling / ground_normal / platform_velocity (read-only; do not re-add to setVelocity) |
Do not use forces or body_type: Dynamic for player locomotion. Keep dynamics for props and debris.
1. Entity with physics
const hi = @import("hikari_game");
// PlayerBehaviour → defineComponent + list next to capabilities
const PlayerLogic = hi.defineComponent(.{
.name = "player_logic",
.storage = .embedded,
.data = struct {
// … fields + update that call setVelocity / physicsState …
},
});
pub const PlayerEntity = hi.defineActor(.{
.archetype = "player",
.components = .{ hi.ComponentRender, hi.ComponentPhysics, PlayerLogic },
});Export defineActor under project src/ like any other actor (First entity). The sample game ships player in the messaging scene.
2. Author the controller (scene or editor)
Scene JSON
{
"id": "player_01",
"archetype": "player",
"transform": {
"position": [0, 1.2, 0],
"rotation_euler": [0, 0, 0],
"scale": [1, 1, 1]
},
"components": {
"render": {
"mesh": "asset://./models/capsule",
"material": "asset://./materials/capsule"
},
"physics": {
"is_character_controller": true,
"collider_shape": "Capsule",
"collider_radius": 0.35,
"collider_half_height": 0.5,
"max_slope_deg": 45,
"step_offset": 0.35
}
}
}Match sample messaging_player in scenes/messaging.json (capsule visual + CCT block).
Enabling is_character_controller forces Capsule + Kinematic. Capsule axis is body local +Y (upright). Render mesh can be any visual; collider size is independent.
| Field | Meaning | Default |
|---|---|---|
collider_radius | Capsule radius | 0.5 if omitted |
collider_half_height | Half-length of the cylindrical segment (excluding caps) | 0.5 |
max_slope_deg | Steepest walkable surface | 45 |
step_offset | Max step height the controller will climb | 0.35 |
Editor
Select the actor → Physics → enable Character, tune radius / half height / max slope / step offset, Save.
3. Drive desired velocity from Zig
Desired velocity is not “current rigid-body velocity you integrate yourself.” Each physics tick the engine calls the capsule move with that vector as desired velocity, then writes grounded flags back onto the component (read next frame via physicsState).
const hi = @import("hikari_game");
// Condensed from sample player_entity.zig (camera-yaw move + move_up jump).
const PlayerBehaviour = struct {
move_speed: f32 = 6.0,
jump_speed: f32 = 7.0,
vertical_velocity: f32 = 0.0,
gravity: f32 = 20.0,
pub fn update(self: *@This(), actor: hi.ActorContext, ctx: *const hi.TickContext) void {
const id = actor.id;
const w = hi.world();
const state = w.physicsState(id);
if (!state.is_character_controller) return;
var local_x: f32 = 0;
var local_z: f32 = 0;
if (w.actionHeld("move_forward")) local_z += 1;
if (w.actionHeld("move_back")) local_z -= 1;
if (w.actionHeld("move_right")) local_x += 1;
if (w.actionHeld("move_left")) local_x -= 1;
// Camera-relative XZ via primary camera yaw (see docs/systems/coordinate-space.md).
const cam = w.primaryCamera();
const yaw = if (cam.isValid() and w.isAlive(cam)) w.rotationEuler(cam)[1] else 0;
const basis = hi.cameraYawBasis(yaw);
var wish = basis.forward * @as(@Vector(3, f32), @splat(local_z)) +
basis.right * @as(@Vector(3, f32), @splat(local_x));
wish[1] = 0;
const len_sq = wish[0] * wish[0] + wish[2] * wish[2];
if (len_sq > 1e-6) {
const inv = 1.0 / @sqrt(len_sq);
wish[0] *= inv * self.move_speed;
wish[2] *= inv * self.move_speed;
}
if (state.grounded) {
self.vertical_velocity = 0;
// Sample binds jump to move_up; add a dedicated "jump" action when ready.
if (w.actionPressed("move_up")) {
self.vertical_velocity = self.jump_speed;
}
} else {
self.vertical_velocity -= self.gravity * ctx.dt;
}
wish[1] = self.vertical_velocity;
w.setVelocity(id, wish);
}
};Sample input_actions.json already binds move_forward / move_back / move_left / move_right / move_up. Full sample: player_entity.zig (also faces walk direction). Prefer w.primaryCamera() over w.find("camera") — scene ids vary (messaging_camera_main in the messaging scene).
Grounded timing: physicsState(...).grounded reflects the previous physics move. That is the usual pattern for jump buffering / coyote-time decisions in update. Kawa reads the same flag as Physics.state(self).grounded.
4. Optional: spawn at runtime
SpawnDesc can initialize the complete physics block before awake, including a
character controller. For a loose pickup / prop spawn, see First runtime spawn:
const w = hi.world();
const id = try w.spawn(&.{
.archetype = "cube",
.id = "pickup_runtime",
.transform = .{ .position = .{ 0, 2, 0 } },
.layer = .loose,
});
_ = id;Verify
- Place a static floor (box or plane) and the player above it (messaging ships
messaging_player). - Play — walk with move actions; character should slide along walls and stand on the floor (
physicsState.grounded == truewhile resting). - Raise a small step under
step_offset— walking into it should climb; taller ledges should block. - Stop — Edit restores authored transforms (play-time CCT pose does not write back).
Common mistakes
| Mistake | Fix |
|---|---|
| Dynamic body + CCT flag | Use the Character checkbox / is_character_controller; body becomes kinematic |
| Box collider “player” | CCT is capsule-only |
| Expecting gravity from the solver | Apply gravity (and jump) yourself into desired velocity .y |
| Reading velocity as post-slide | Treat setVelocity as the wish vector; use physicsState.grounded for support |
Next
- First physics — floors, props, triggers around the player
- First input action — bind move / jump
- First messages — react to pads / pickups via collisions