Skip to content

Projectiles ​

serverside spawns · clientside + serverside callbacks

A projectile is a server-simulated shot — an arrow, a bullet, a fireball — that flies in a straight line, renders with a gani on every client in the level, and stops on the first wall, NPC or player it touches. You spawn one with a single call, and the engine takes care of flight, collision, rendering and telling scripts what happened.

Use projectiles when something needs to travel and hit. For purely visual effects (sparks, smoke, a muzzle flash) use particles instead: they're clientside and cost no network traffic.

How it works ​

  • The server is authoritative. It simulates each flight at 20 ticks per second, sub-stepping so fast shots can't tunnel through thin walls.
  • Only the spawn parameters and the final stop go over the network. Every client simulates the flight itself from the spawn, predicting walls and lifetime locally, and snaps to the server's stop position when it arrives.
  • The flight uses the level's collision tiles (type 22 blocks). Blocking NPCs don't stop projectiles as walls; NPCs are hit through their shape instead (below).

Spawning ​

Projectiles are spawned serverside with shoot, which exists on every level object:

WhereCallCoordinates
Weapon server half / server script, in a player-scoped handlerlevel.shootThe triggering player's space
Any server script holding a playerplayer.level.shootThat level's (or gmap's) space
Serverside NPC scriptthis.level.shootThe NPC's member-local tiles
ts
shoot(x, y, gan, angle, speed, lifetime, data?) → Projectile | null
ArgumentMeaning
x, yThe projectile's center, in tiles
ganThe animation it renders, e.g. 'arrow'
angleRadians, Graal-style: 0 = right, Math.PI / 2 = up, Math.PI = left, 3 * Math.PI / 2 = down
speedTiles per second (max 100)
lifetimeSeconds before it expires (max 60)
dataAny JSON-serializable payload, handed to every callback

It returns a Projectile handle, or null on bad arguments.

Clients can't spawn projectiles. The usual pattern is a weapon whose client half sends the player's facing direction and whose server half fires. A minimal version (a fuller one might read its stats from the equipped item):

ts
// gun.client.ts
export function onKeyPressed(key: string) {
    if (key === 'D') triggerServer('weapon', this.name, 'fire', player.dir)
}
ts
// gun.ts (server)
const ANGLES: Record<string, number> = {
    right: 0, up: Math.PI / 2, left: Math.PI, down: 3 * Math.PI / 2,
}

export function onActionServerSide(player: Player, action: string, dir: string) {
    if (action !== 'fire') return
    const angle = ANGLES[dir] ?? 0
    // Start 2.5 tiles out from the player's center, clear of their own
    // 2x2 body — otherwise the shooter is the first thing it hits.
    const x = player.x + 1 + Math.cos(angle) * 2.5
    const y = player.y + 1 - Math.sin(angle) * 2.5
    level.shoot(x, y, 'arrow', angle, 14, 3, { shooter: player.account, damage: 10 })
}

Note the - Math.sin(angle): tile Y grows downward, so an "up" angle moves toward smaller Y. The engine's velocity is (cos(angle), -sin(angle)) × speed.

Projectiles hit their shooter

There is no owner exclusion: a projectile spawned inside the shooter's own body hits them immediately. Spawn it outside (the player's body is 2×2 tiles; the example uses 2.5 tiles from the center).

level vs this.level vs player.level

The global level only exists synchronously inside a player-scoped handler (onActionServerSide, onPlayerJoined, …). In a setTimeout callback or after an await, level.shoot returns null. Keep player.level instead — it's bound to the level by name and works later too.

In NPC scripts, always use this.level.shoot: a bare level there is the player-scoped global, which returns null.

The gani ​

gan names a gani like any character animation. The engine picks the gani's direction (up/left/down/right) from the flight angle, so a four-direction gani shows the right sprite for the four cardinal angles. The gani is drawn half a tile up-left of the projectile's center, so a 16×16 sprite placed at offset 8 8 in each direction ends up centered — see the projectile gun tutorial, which offers a minimal arrow.gan to download.

Destroying mid-flight ​

Projectile.destroy() removes a projectile silently: no onShotAt or onShot fires anywhere. It returns false if the projectile already stopped (so calling it twice is safe). Projectile.id is unique for the server run.

ts
const p = level.shoot(x, y, 'fireball', angle, 8, 10, null)
if (p) setTimeout(() => p.destroy(), 500)   // fizzle after half a second

What happens when it stops ​

A projectile stops for one of four reasons, checked in this order each sub-step. Each fires different callbacks:

StopServersideClientside
Wall — its 0.5×0.5-tile box touches a blocking tile (or leaves the level)onShotAt(x, y, data) on every serverside NPC script of the level under the stop pointonShotAt(x, y, data) on every weapon and clientside NPC script, on every client in the level
NPC — its center enters an NPC's shapeonShot(data) on that NPC's serverside scriptonShot(data) on that NPC's clientside script, on every client
Player — its box overlaps a player's full 2×2 body(nothing)onShot(data) on the hit player's weapon scripts only
Lifetime expiredsame as Wallsame as Wall

A destroyed projectile fires nothing.

(x, y) in onShotAt is where it stopped (the last clear position before a wall), in the projectile's coordinate space: level tiles, or gmap-global tiles on a gmap. data is the payload you passed to shoot, JSON round-tripped.

Server weapons don't get onShotAt

Serverside, only NPC scripts in the level receive onShotAt/onShot. A weapon's server half is not notified — if it needs to know, have the client half or an NPC report back.

NPC hits ​

An NPC's shape is what setShape set, or its image's pixel size if it never called it — the same hitbox triggerAction uses. An NPC with neither can't be hit (the projectile flies through). dontblock() doesn't affect this: non-blocking NPCs are still hit.

ts
// serverside NPC script: a target dummy
export function onCreated() {
    this.image = 'dummy.png'
    this.hp = 3
}

export function onShot(data: any) {
    this.hp--
    if (this.hp > 0) {
        this.chat = `Ouch! (${data?.shooter})`
    } else {
        this.chat = 'Down! Resetting...'
        this.hp = 3
    }
}

onShotAt also reaches NPCs in levels nobody has entered yet: the server activates the level first so its NPCs can react.

Player hits ​

When a projectile hits a player, only that player's own client runs onShot(data) on its weapons. There is no serverside callback, so health, knockback and death are applied by the hit client — typically by reporting back to a server script:

ts
// health.client.ts
export function onShot(data: any) {
    triggerServer('weapon', this.name, 'hit', data?.damage ?? 1)
}

Trust

That report comes from the victim's client, which could simply not send it. If a hit matters for fairness, validate it serverside (e.g. that the projectile was plausible and the damage matches the weapon) rather than trusting the numbers the client sends.

Late joiners miss callbacks

A client that enters the level while a projectile is already in flight never received its spawn, so it doesn't draw that projectile and ignores its stop — including an onShot for itself or an NPC. Callbacks on the server are unaffected.

Gmaps ​

On a gmap, projectiles fly in gmap-global space and cross member seams seamlessly:

  • level.shoot / player.level.shoot take gmap-global coordinates when the player is on a gmap.
  • this.level.shoot in an NPC takes the NPC's member-local coordinates; the engine promotes the projectile into gmap space for you.
  • onShotAt coordinates are gmap-global, on both sides, and reach the serverside NPCs of the member level under the stop point.
  • Every player on the gmap receives the projectile, not just those whose loaded window includes it.
  • A projectile that leaves the gmap's grid stops as if it hit a wall.

See Levels & gmaps for converting between member and gmap coordinates.

Try it ​

A small test weapon covers the whole system: one key shoots in the facing direction, another shoots and calls destroy() on the handle after 500 ms, and a third spawns an arrow a few tiles in front of you flying back at you — a player hit without needing a second client. Give it onShotAt/onShot handlers that echo every callback so you can watch them in the client console.

See also ​