Preserve integrated audio, camera, authored map starts and trigger exports when resolving overlaps. Bound frame-log buffering to 4,096 rows with nonblocking writes and dropped-row reporting.
18 KiB
Engine observations and controls (API 2)
The SDK exposes engine primitives. Game rules, turn order, trick matching, scoring policies, and win conditions belong in Lua mods. No host API depends on a mod ID or a particular game mode.
Player and scoring observations
sdk.player.read() reads the local skater. sdk.player.skaters() returns a table
keyed by peer ID; sdk.player.skater(id) reads one entry. Entries include names,
pose, velocity, angular velocity, movement mode, grind, stance, score, and named
input intents. sdk.net.players() lists connected peers; their first skater
observation may arrive later.
trick/trick_seq: latest trick announcement, including midair tricks.landed_trick/landing_seq: latest confirmed, banked on-board scoring sequence. The counter advances only after successful native scoring settlement, not on announcement, bail, offboard completion, or teleport. The label remains available until the next confirmed result.bail_seq: counter incremented once when entering a native wipeout.
Counters are observation cursors, not one-frame pulses. Remember the previous
counter, establish a baseline when a peer appears, and handle a lower counter as
a reset. Remote snapshots carry the latest state, not an unbounded event history;
intermediate events may be coalesced if several occur between received updates.
Trick labels are display strings, not a structured trick taxonomy. A mod chooses
its own matching policy. sdk.capabilities.skater >= 4 includes these counters.
local previous = {}
return {on_fixed_update = function()
for id, skater in pairs(sdk.player.skaters()) do
local before = previous[id]
if before and skater.landing_seq > before then
sdk.log(skater.name .. " landed " .. skater.landed_trick)
end
previous[id] = skater.landing_seq
end
end}
Native player movement and session authority
sdk.player.teleport({position={x,y,z}, heading=radians, velocity={x,y,z}})
uses native player travel. Heading defaults to zero; velocity is optional. This
only moves the local skater. Coordinates are bounded to +/-100000 and velocity
components to +/-200. Invalid commands fail validation.
sdk.session.info() reports the transport host and current session authority.
Authority is global to the session, initially the host. The current authority may
sdk.session.transfer(peer) to another connected player; the transport host
ratifies the transfer asynchronously. The host can reclaim with
sdk.session.claim(). The current authority can use
sdk.session.teleport(peer, options) to relocate a connected skater. Receivers
validate the sender, authority generation, command sequence, and movement values.
Reconnect/host changes reset authority; the host reclaims it if its holder leaves.
These operations do not implement turns or game rules.
Cameras and world volumes
sdk.camera.watch(peer) follows a remote skater's presented pose;
sdk.camera.watch(nil) releases spectating. Camera overrides retain the existing
single-mod ownership rule.
sdk.volumes.box(key, {position={x,y,z}, size={x,y,z}, rotation={x,y,z,w}, visible=true, color={r,g,b}, opacity=0.3}) creates or updates an oriented box.
sdk.volumes.read(key).inside lists peer IDs whose observed root positions lie
inside it. This is a point overlap query, not a collider or a skater hull test.
Boxes belong to their creating mod, max 32 per mod; remove with
sdk.volumes.remove(key). Changes become observable on the next snapshot.
Trigger volumes (sdk.triggers, capability triggers 1)
The engine tracks the map's named trigger volumes the way retail Skate 3 does:
each fixed tick the local player (body "player") and every tracked body get a
query cylinder (retail: radius 0.34 m, spanning the skater from just below the
feet to just above the head) which is tested against each volume's oriented box.
Changes post on_event events before on_fixed_update:
{name="trigger_entered"|"trigger_exited", body="player"|"mod:<mod>:<key>",
volume=<id>, volume_name=<short name>, group="challenge"|"stairs"|"camera",
instance_id=<16 hex digits or nil>, link_guid=<16 hex digits or nil>}
Retail map volumes come from the converted map (maps/<Map>.triggers, written by
setup); their id is the retail instance id challenge scripts use (e.g.
SkateSchool's map-wide tut_sksc_reset_vol01 is 2c7017060025128f). Custom maps
carry their own in a <Map>.triggers sidecar or a TVOL extension (same JSON;
see docs/hails-additions/24-trigger-volumes.md).
sdk.triggers.list()— every volume:{id, name, full_name, group, source="map"|"mod", owner, instance_id, link_guid, center, axes, rotation, half_extents, fatness, aabb_min, aabb_max, enabled, inside={bodies}}.sdk.triggers.get(id_or_name).sdk.triggers.inside(body)— volume ids the body (default"player") is in.sdk.triggers.box(key, {center={x,y,z}, half_extents={x,y,z}, rotation={x,y,z,w}, name="...", group="challenge"})— a mod volume with idmod:<mod>:<key>(max 64 per mod);sdk.triggers.remove(key).sdk.triggers.set_enabled(id, false)switches a map volume off for everyone until the same mod switches it on or stops.sdk.triggers.track(key, {radius=0.34, length=0})follows one of the mod's physics bodies (body idmod:<mod>:<key>, max 16);sdk.triggers.untrack(key). The cylinder stands on the body's position (like the player's feet) and reacheslengthmetres up, plus retail's pads (2 cm below, 8 cm above). Positions are taken after the dynamics step, so their events lag one tick.sdk.triggers.configure({radius=, length_scale=, length_pad=, foot_pad=})changes the query-cylinder constants (one mod at a time);configure()restores retail. The player's cylinder is built like retail's skater from its feet (ground point), head and hips: radius 0.34, half-heightlength_scale * |head - feet| + length_pad(0.5, 0.05), one endfoot_pad(0.02) below the feet.- Only
challengevolumes posttrigger_entered/trigger_exited; retail'sstairsandcameragroups have no tracked bodies (they are listed, not tracked).
Removing a volume never posts an exit (retail); a body that stops being tracked
exits everything it was in. Everything a mod set is undone when it stops; mod
volumes, switches and tracked bodies are also cleared when the world changes
(re-add them on world_changed).
sdk.camera.capture(key, {position={x,y,z}, look_at={x,y,z}, fov=radians, width=256, height=256}) creates or updates an offscreen camera. FOV defaults to
70 degrees (in radians), range 0.2–2.5. Dimensions must be multiples of 16 between
64 and 512. Limits: 2 captures per mod, 4 overall. Bind a capture using
sdk.graphics.mesh_buffer(key, {capture="camera-key"}), then write mesh geometry
and UVs with the existing buffer methods. texture and capture are mutually
exclusive. Capture surfaces are excluded from capture views to prevent recursive
render-target sampling. Clearing/resizing a capture updates its bound materials.
Release it with sdk.camera.clear_capture(key).
sdk.graphics.mesh(key, {path="model.glb", opacity=0.5}) supports opacity 0–1,
including replication. Camera captures and volumes are local, mod-owned resources
and are cleared when their mod stops or the world changes.
Multiplayer names
Edit Your name in the Multiplayer menu. Names are saved to
settings/player.json, transmitted to peers, exposed as skater.name, and drawn
above remote players. Supported characters: ASCII letters, digits, spaces,
hyphens, and underscores, up to 16 characters. Empty names display as Player.
See skate.lua for the complete annotated API. The sample mod in
examples/game-of-skate implements game rules using these generic observations.
Mod menus
sdk.ui.menu(key, {title=..., items=...}) registers or replaces a mod-owned menu
under Mods. Items have id, label, optional description, enabled, and
children. Children create nested pages; leaves emit an action only to the owner.
Limits are 8 menus/mod, 64 items/menu, 4 levels, with unique item IDs per menu.
Menus support mouse, keyboard, and controller navigation. Remove them explicitly
with sdk.ui.remove_menu(key); they also disappear when the mod stops.
return {
on_load=function()
sdk.ui.menu("challenges", {title="Challenges", items={
{id="races", label="Races", children={
{id="sprints", label="Sprints", children={
{id="downtown", label="Downtown run"}
}}
}}
}})
end,
on_event=function(event)
if event.name=="menu_action" and event.menu=="challenges" then
-- The mod owns what selecting downtown does.
sdk.log("Selected "..event.item)
end
end
}
Callbacks run even while paused, so start/stop/settings actions can take effect from the menu. Simulation callbacks remain paused. The S.K.A.T.E. example uses this interface for explicit host-only Start session and Stop session; enabling the mod registers its menu without starting a match.
Native contacts and joint overrides
sdk.player.read().bailing, .mode, .state, and .category describe native
player state. sdk.player.physics() adds local ragdoll, partial_ragdoll,
parts, joints, and contacts, along with a simulation tick and dt.
The convenience methods contacts(), joints(), and parts() return those arrays.
Detailed native physics observations are local; remote mods can publish selected
effects or results through the generic network API.
Contacts are eligible solved native contact reports, capped at 128/frame. They
include both bodies, world point/normal, normal and friction force vectors,
combined static/dynamic friction coefficients, and material tags. Force uses the
native solver's post-solve conversion, not an estimate from player speed; impulse
is that force times simulation dt. These include sustained support forces, not
just new impacts. Use the tick to avoid processing a frame twice, and apply your
own threshold/history policy. Body kind skater carries a physical part index;
external includes attached proxies and target bodies. No injury is inferred by
the engine.
Joint entries expose their native names, zero-based IDs, parent/child parts,
current angle limits, and override owner. sdk.player.set_joint(joint.index, options) replaces the calling mod's override. Limits are radians, 0.01–pi;
free_swing=true, free_twist=true removes angular limits. Also set
drive_enabled=false to suppress the child's animation drives. Linear attachment
constraints remain intact: the limb becomes loose, not detached. Another mod
cannot overwrite an owned joint. Overrides are applied to temporary solver input
so native tuning is preserved; reset_joint(index), reset_joints(), mod stop,
or world change restores native behavior. Ordinary state transitions and recovery
do not erase an override; the mod decides when to restore it.
-- Example policy, entirely in Lua: relax the left elbow during a hard bail.
local last_tick
return {on_fixed_update=function()
local physics=sdk.player.physics()
if physics.tick==last_tick then return end
last_tick=physics.tick
if not sdk.player.read().bailing then return end
for _, joint in ipairs(physics.joints) do
if joint.name=="JOINT_LEFT_ARM_FOREARM" then
for _, contact in ipairs(physics.contacts) do
local a,b=contact.a,contact.b
if (a.kind=="skater" and a.index==joint.child)
or (b.kind=="skater" and b.index==joint.child) then
local f=contact.force
-- An illustrative threshold; calibrate it for your mod's behavior.
if f[1]^2+f[2]^2+f[3]^2 > 5000^2 then
sdk.player.set_joint(joint.index,
{free_swing=true,free_twist=true,drive_enabled=false})
end
end
end
end
end
end}
Frame-time statistics
sdk.snapshot.frame is a read-only view of the engine's frame timing (the same
numbers as the graphics menu's frame-time counter and the SKATE_FRAME_LOG log):
ms (last frame), fixed_steps (physics steps run in that frame), main_ms /
fixed_ms (CPU time of the engine's main thread / of the physics steps in that frame), hitch,
and over the last window_s seconds fps, mean_ms, median_ms, low_1_ms
(1 % low = 99th-percentile frame time), low_01_ms (0.1 % low), worst_ms and
hitches (frames longer than twice the median of the previous 120). The window
values refresh four times a second. Reading them costs nothing extra; mods cannot
change them. Use them for adaptive effects (drop a costly effect while
low_1_ms is high) or a custom performance HUD.
UI updates during pause
on_ui_update({dt, paused}) runs every presentation update with a fresh snapshot,
including while the pause menu is open. Use it to refresh menus when network roles
or other observed state changes. It does not advance sdk.time or simulation
timers. sdk.capabilities.menus >= 2 advertises this callback. Keep gameplay in
on_fixed_update/on_update. Disabled menu actions show their description and do
not invoke callbacks or report a mod failure.
Lua-defined pause sections
Pass section to sdk.ui.menu to place the menu in the main pause sidebar:
sdk.ui.menu("skate", {section="Gamemodes", title="SKATE", items={
{id="start", label="Start session"}, {id="stop", label="Stop session"}
}})
Section names are supplied by Lua and shared by menus with the same name. The
engine imposes no game rules. Omit section to use the Mods menu. Menu removal or
mod unload removes its entry; empty sections disappear. Back returns to the
section that opened the menu. Limits: 8 custom sections, 64 section menus total,
32 bytes per section name. sdk.capabilities.menus >= 3 supports this placement.
Camera angle and stock shots
The native gameplay camera is Skate 3's stock camera graph. Its Camera Angle
setting (pause menu SKATER > Camera angle, settings/camera.json) picks the
graph's Low or High branch, which choose different stock shots (bl_chase vs
bl_high_chase, ...). With sdk.capabilities.camera >= 4:
sdk.camera.angle()returns{selected, active, owner, shot, tuned}: the player's setting, the angle in use, the mod forcing it (or nil), the current stock shot and ashot -> modtable of tuned shots.sdk.camera.set_angle("low" | "high")forces an angle;nilhands it back to the player's setting. One mod at a time; the player's saved choice is untouched.sdk.camera.tune_shot(shot, patch)replaces stock values of one shot (and of every blend tree using it). Keys are the retail attribute names in their units:PositionDistance(m),PositionElevation,PositionHeading,FramingRoll,FramingYaw,FramingPitch(degrees),FramingLensLength,ReferenceBoardOffset,SmoothingDirection,SmoothingElevation,SmoothingYaw,SmoothingPitch,TransitionTime(s).nilrestores the stock shot. One mod per shot; unknown shots, keys or out-of-range values fail the command (usesdk.commands.requestto get the error back).
Disabling, failing or reloading the mod releases its angle and shot tunings.
Local actor suspension and peer cameras
sdk.player.suspend(true) freezes the local native simulation, hides its skater,
and removes its multiplayer collision participation. false releases only the
calling mod's suspension. Mod retirement, errors, and world changes release its
ownership. Multiple mods may suspend independently. Available with
sdk.capabilities.player_control >= 1; skater observations expose suspended.
sdk.camera.watch(peer) mirrors the connected player's transmitted gameplay
camera transform and vertical FOV (sdk.capabilities.camera >= 3). Network
latency still applies. Older peers without camera packets use the body-follow
fallback. nil restores the native camera. This does not transfer player input.
sdk.net.info().host_id is the authenticated host player ID from the roster,
not a Steam ID or socket endpoint. It is "0" until the host roster arrives.
Use this player ID with sdk.net.read, sdk.player.skater, and camera watching.
The Game of SKATE example keeps its turn roster and saved position in an origin
record and its compact changing state in skate. It waits for every participant
to acknowledge suspension, then resets the active player using
sdk.player.teleport. Only that player's post-reset landing/bail counters count.
Late joiners wait for the next match; a missing enabled mod aborts preparation
with an actionable status. Stop/unload returns participants to their pre-match
positions and restores their cameras. Enable the same package version everywhere.
Native player overlap (capability player_overlap = 1)
sdk.physics.read(key).player_overlapping reports whether the enabled native
board/skater collision shapes intersect the mod-owned body. Sensors require
intersection; solids include the native solver's 0.025m contact margin. This is
an explicit geometric query independent of collision masks, updated before each
fixed callback. Suspended/attached players report false. No duplicate Rapier
player is simulated. Detect transitions in Lua to count entries rather than
counting every frame of sustained contact. It is a sampled query, not a swept
trigger: use suitably thick sensor volumes for fast-moving actors.
Native foot-support, air-trajectory and camera queries include mod-created solids, preserving nearest-hit ordering and native map behavior.
Settled scoring details (landed_details = 1)
Player observations include landed_trick_base (engine-authored, unlocalized
base label), landed_spin_degrees (signed body rotation, excluding board
rotation), landed_clean and landed_sketchy. These are captured together when
landing_seq advances and replicated with that observation. They describe the
last settled trick; compare sequence numbers to distinguish it from old results.
Game rules and label aliases remain in Lua. The existing localized
landed_trick remains available for display.
See GENERAL_API.md for native bodies, joint and part overrides, contact lifecycle, input overrides, graph access and acknowledged commands.