Appearance
Camera, input & movement
clientsideEverything on this page runs in clientside scripts — weapon *.client.ts halves, clientside NPC scripts and client class halves. The server never sees the keyboard, mouse or camera: when input should change game state, the client script tells its server half with triggerServer.
It covers four things that are easiest to understand together:
- Input — polling keys and the mouse.
- Coordinate spaces — world tiles vs screen pixels, and converting between them.
- The camera — following the player, or pinning it somewhere else.
- Movement — the built-in arrow-key walking, and replacing it with your own.
Keyboard
keydown(key) returns whether a key is held this frame. It's a direct call that reads live input, so it's the right tool for anything continuous, like movement:
ts
export function onUpdate(delta: number) {
if (keydown('space')) charge += delta
}Key names are case-insensitive: 'up', 'down', 'left', 'right', letters 'a'..'z', digits '0'..'9', and named keys such as 'space', 'enter', 'escape', 'tab', 'leftshift', 'leftcontrol' or 'f1'. An unknown name just returns false.
keydown is always false while the game window is inactive and while the player is typing in the chat bar or a text field — so a hotkey never fires because someone typed the letter in chat.
Presses vs holds
For one-shot actions (open a menu, fire once) you want the press, not the hold. Two options:
Export
onKeyPressed(key)— fired once per press, never repeating while held, on every weapon, clientside NPC script and joined class, beforeonUpdatein the same frame. Itskeynames are capitalized ('T','Space','LeftShift','F1').tsexport function onKeyPressed(key: string) { if (key === 'I') toggleInventory() }Edge-detect a
keydownpoll yourself, which is handy when you're already polling inonUpdate:tslet fWasDown = false export function onUpdate() { const fDown = keydown('f') if (fDown && !fWasDown) triggerServer('weapon', this.name, 'shoot', player.dir) fWasDown = fDown }
Keyboard input aimed at a focused GUI control (a text field, say) arrives as that control's keydown/keyup events instead; see GUI controls.
Mouse
| Function | Returns |
|---|---|
| mousex() / mousey() | Cursor position in screen pixels, from the window's top-left |
| mousedown(button) | Whether 'left', 'right' or 'middle' is held this frame |
| mouseonui() | Whether the cursor is over any GUI control (windows, buttons, the chat bar…) |
All are direct calls returning live values. While the window is inactive the position freezes and mousedown returns false. Unlike keydown, the mouse is not disabled while the chat bar has focus.
There is no world-click event: detect clicks by edge-detecting mousedown in onUpdate, and check mouseonui() so a click on a button or window doesn't also act on the world underneath:
ts
let wasLeft = false
export function onUpdate() {
const left = mousedown('left')
if (left && !wasLeft && !mouseonui()) {
const tx = Math.floor((mousex() + camerax()) / 16)
const ty = Math.floor((mousey() + cameray()) / 16)
triggerServer('weapon', this.name, 'clicked', tx, ty)
}
wasLeft = left
}Clicks on GUI controls are better handled with the controls' own .on('click', …) events; see GUI controls. Chat bubbles don't count as UI for mouseonui.
Screen size
ScreenWidth and ScreenHeight are the current viewport size in pixels. They're global values, not functions, but they're live: they track window resizes. Read them each time you lay something out rather than caching them in onCreated.
ts
const img = findimg(1)
img.screen = true
img.text = 'Paused'
img.style = 'c'
img.x = ScreenWidth / 2
img.y = ScreenHeight / 2Coordinate spaces
The client draws in two spaces:
| Space | Units | Scrolls with the camera? | Used by |
|---|---|---|---|
| World | tiles (1 tile = 16 px) | yes | player.x/y, NPCs, level tiles, gettile/onwall, world-mode findimg images, projectiles |
| Screen | pixels from the window's top-left | no | mousex()/mousey(), ScreenWidth/Height, GUI controls, screen-mode images (img.screen = true) |
The bridge between them is the camera offset: camerax() and cameray() return the world pixel at the screen's top-left corner. So:
ts
// screen px -> world px -> tile
const worldPx = mousex() + camerax()
const tileX = Math.floor(worldPx / 16)
// tile -> screen px (e.g. to put a GUI marker over the player's head)
const screenX = (player.x + 1) * 16 - camerax()
const screenY = player.y * 16 - cameray()On a gmap, the world space is the whole gmap: tiles are gmap-global (see Levels & gmaps).
Prefer world-mode images for things in the world
An image that should stay attached to something in the world (a marker over an NPC, a target reticle on a tile) is simplest as a world-mode findimg image with tile coordinates — it scrolls with the camera by itself. Convert to screen pixels only for GUI controls or screen-mode images.
The camera
By default the camera centers on the local player every frame (on where the player ended up after this frame's movement). It isn't clamped to the level: near the edge of a small level you'll see black beyond it.
setfocus(x, y) pins the camera so world tile (x, y) sits at the center of the screen instead — for a cutscene, a spectator view, or a map preview. resetfocus() returns it to following the player.
ts
// Pan to the town gate for three seconds.
setfocus(64, 20)
setTimeout(() => resetfocus(), 3000)- Both are batched calls: they apply at the next flush (by the end of the current handler at the latest).
camerax()read straight aftersetfocus()in the same handler still returns the old offset; read it on a later frame. - The focus stays pinned across level changes until
resetfocus(). - When a weapon is removed from the player, the client resets the focus (so a removed weapon can't leave the camera stuck).
Movement
Built-in movement
Out of the box, the arrow keys walk the local player at 6 tiles per second, resolving collision per axis (so walking diagonally into a wall slides along it) against the player's lower-body footprint, and switching between the idle and walk animations. It never stomps an animation a script started, such as a sword swing.
The position is synced to the server continuously; other players see you move and the server's player.x/y follow.
Moving the player from a script
Clientside, player.x, player.y and player.dir are writable. Writes are batched: any number of writes in one handler fold into a single move applied at flush, but reads see your pending writes immediately, so player.x += 1 twice moves by 2. Positions are clamped to the level bounds when the write applies.
What a write does depends on whether built-in movement is on:
- Built-in movement enabled — the write is collision-checked per axis like a walk step: the player won't be pushed into a wall.
- Built-in movement disabled — the write is a free teleport (still clamped to the level). Your script owns collision.
(To move a player to another level, or to teleport them authoritatively, use the serverside player.warpto.)
Custom movement
disabledefmovement() turns the built-in arrow-key movement off so a weapon can drive the player itself — for dashing, ice physics, grid-locked movement, vehicles. enabledefmovement() turns it back on. Both are batched. Position sync to the server keeps running either way.
A custom movement script reads keys with keydown, tests its next position with onwall on the lower-body footprint(x, y + 1, 2, 1), and writes player.x/y. For example, a minimal weapons/movement.client.ts:
ts
const SPEED = 6 // tiles/sec, same as the built-in movement
export function onCreated() {
disabledefmovement()
}
export function onUpdate(delta: number) {
let dx = 0, dy = 0
if (keydown('up')) dy -= 1
if (keydown('down')) dy += 1
if (keydown('left')) dx -= 1
if (keydown('right')) dx += 1
if (dx === 0 && dy === 0) {
if (player.ani === 'walk') setAni('idle')
return
}
if (player.ani === 'idle' || player.ani === '') setAni('walk')
// Already inside a wall (e.g. the level was edited under us)? Let the
// player walk out instead of freezing them.
const escaping = onwall(player.x, player.y + 1, 2, 1)
// Per-axis, so diagonal moves slide along walls. player.x reads back the
// pending write, so the Y test already sees the new X.
const nx = player.x + dx * SPEED * delta
if (escaping || !onwall(nx, player.y + 1, 2, 1)) player.x = nx
const ny = player.y + dy * SPEED * delta
if (escaping || !onwall(player.x, ny + 1, 2, 1)) player.y = ny
player.dir = dy < 0 ? 'up' : dy > 0 ? 'down' : dx < 0 ? 'left' : 'right'
}Disabling movement means owning collision
Once disabledefmovement() is active, player.x/y writes teleport. If you forget the onwall checks the player walks through walls. And don't leave both systems running: if a script moves the player and built-in movement is on, both apply every frame.
Movement comes back when the weapon goes
When a weapon is removed from the player, the client re-enables built-in movement so the player is never left unable to move. A weapon that only disables movement temporarily (a stun, a cutscene) should still call enabledefmovement() itself when it's done.
Animation names in the example assume no replacements; if your game uses replaceAni, compare against player.replacedAnis.walk ?? 'walk' — see Players.
See also
- Levels & gmaps — tiles, collision types, gmap coordinates
- Images, text & particles — world vs screen images
- GUI controls — mouse and keyboard events on controls
- Events & triggers — sending input to the server