Appearance
4. Projectile gun
In this tutorial you build a blaster weapon and something to shoot at:
- Press D to fire a projectile in the direction you're facing. It flies until it hits a wall, an NPC or a player, or until its lifetime runs out.
- Where a shot stops on a wall, a small spark flashes and fades.
- A training dummy NPC takes damage, shows its health as a bar in its chat bubble, pops floating damage numbers that every player sees, and respawns after being destroyed.
- Other players take damage too. The screen flashes red when you're hit, your
clientr.hpdrops (shown by the HUD from tutorial 2), and at zero you are knocked out and sent back to the start.
What you'll learn
- Spawning projectiles with
level.shoot: coordinates, Graal-style angles, speed, lifetime and thedatapayload. - The four places a projectile can end up, and which callback fires for each:
onShotAt,onShoton an NPC, andonShoton the hit player's weapon. - Keeping NPC state on
this, NPC timers, and client-only effects in clientside NPC scripts. - Applying damage with flags, and not trusting client-reported hits.
Files, in docs/examples/projectile-gun/: weapons/blaster.ts, weapons/blaster.client.ts, and the NPC scripts npcs/target.npc.ts and npcs/target.npc.client.ts.
You need a projectile animation
level.shoot draws the projectile with a .gan animation, and the flight angle picks the gani direction. The example uses arrow. Download arrow.gan and arrow.png, then upload them in GRC's File Browser (right-click a folder, Upload files here…): arrow.gan into assets/ganis/ and arrow.png into assets/images/. Or make your own in the GANI Editor and change GANI in blaster.ts. Without the asset, projectiles still fly and hit, but you can't see them.
How projectiles work
Projectiles are server-owned. Only serverside code can spawn one: the level global in weapon and server scripts, or this.level.shoot in NPC scripts. The server simulates each flight and decides what it hits. Clients receive only the spawn parameters and the final stop, and they animate the flight themselves in between. A projectile stops at the first of:
| What happened | Callback | Where it fires |
|---|---|---|
| hit a blocking tile | onShotAt(x, y, data) | serverside NPCs in that level, and every clientside weapon and NPC script there |
| lifetime ran out | onShotAt(x, y, data) | same |
| hit an NPC's shape | onShot(data) | that NPC's serverside script and its clientside script on every client |
| hit a player | onShot(data) | only the hit player's clientside weapon scripts |
data is whatever JSON-serializable payload you passed to shoot, delivered unchanged to every callback. See Projectiles.
Step 1: fire on key press
ts
const COOLDOWN_MS = 300
export function onCreated() {
this.lastFire = 0
this.effects = [] as { id: number; age: number; life: number; kind: 'spark' | 'flash' }[]
this.nextImg = 100
}
export function onKeyPressed(key: string) {
if (key !== 'D') return
// Client-side cooldown: don't spam the server with shots it will reject.
const now = Date.now()
if (now - this.lastFire < COOLDOWN_MS) return
this.lastFire = now
triggerServer('weapon', this.name, 'fire', player.dir)
}The client only asks to fire. It sends its facing direction, player.dir, because the server's Player snapshot doesn't carry one. The cooldown here saves pointless network traffic. The server enforces its own.
Step 2: spawn the projectile
ts
const GANI = 'arrow' // the .gan the projectile renders with
const SPEED = 20 // tiles per second (max 100)
const LIFETIME = 1.5 // seconds (max 60)
const DAMAGE = 2
const COOLDOWN_MS = 300
// Graal-style angles in radians: 0 = right, PI/2 = up.
const ANGLES: Record<string, number> = {
right: 0,
up: Math.PI / 2,
left: Math.PI,
down: 3 * Math.PI / 2,
}
const lastFire = new Map<number, number>()
function fire(player: Player, dir: unknown) {
// The client has a cooldown too, but only this one counts. A little
// slack keeps honest clients from being rejected by timing jitter.
const now = Date.now()
if (now - (lastFire.get(player.id) ?? 0) < COOLDOWN_MS * 0.8) return
lastFire.set(player.id, now)
const angle = ANGLES[String(dir)] ?? 0
// The player's body is 2x2 tiles from (x, y): (x+1, y+1) is its center.
// Spawn 2.5 tiles out so the shot starts clear of the shooter. Screen y
// grows downward, hence the minus on sin.
const x = player.x + 1 + Math.cos(angle) * 2.5
const y = player.y + 1 - Math.sin(angle) * 2.5
const shot = registerShot(player)
// `data` rides along to every callback: onShotAt, onShot (NPC and player).
level.shoot(x, y, GANI, angle, SPEED, LIFETIME,
{ weapon: 'blaster', shot, shooter: player.account, damage: DAMAGE })
}- Angles are radians, Graal-style:
0is right andPI/2is up. Screen y grows downward, which is why the spawn offset subtractssin(angle). - Coordinates are tiles, and
(x, y)is the projectile's center. A player occupies the 2×2 tiles from(player.x, player.y), so(x + 1, y + 1)is the body's center. Spawning 2.5 tiles out keeps the shot from hitting its own shooter. - On a gmap,
player.x/yand the projectile are both in gmap-global tiles, and shots fly across member-level seams. speedis tiles per second (max 100) andlifetimeis seconds (max 60).levelis the current player's level. LiketriggerClient, it only works synchronously inside a player-scoped handler, andshootreturnsnullotherwise.shootalso returns aProjectilewhosedestroy()removes it mid-flight without firing any callbacks.
Step 3: effects where shots land
ts
// A projectile stopped on a wall or ran out of lifetime somewhere in this
// level - fires on every client in the level, for everyone's projectiles.
export function onShotAt(x: number, y: number, data: any) {
if (data?.weapon !== 'blaster') return
const spark = findimg(this.nextImg)
spark.x = x - 0.25 // world mode: tiles; (x, y) is the stop point
spark.y = y - 0.25
spark.width = 8 // explicit sizes are pixels
spark.height = 8
spark.tint = '255,200,80'
spark.layer = 4 // above players
this.effects.push({ id: this.nextImg++, age: 0, life: 0.4, kind: 'spark' })
}
// A projectile hit THIS client's player. Only the victim's client hears it,
// so it reports the hit; the server checks the shot id and applies damage.
export function onShot(data: any) {
if (data?.weapon !== 'blaster') return
triggerServer('weapon', this.name, 'hit', data.shot)
const flash = findimg(this.nextImg)
flash.screen = true
flash.x = 0
flash.y = 0
flash.width = ScreenWidth
flash.height = ScreenHeight
flash.tint = '255,0,0'
flash.alpha = 0.35
this.effects.push({ id: this.nextImg++, age: 0, life: 0.3, kind: 'flash' })
}onShotAt fires on every client in the level, for everyone's projectiles, so the handler checks data.weapon to react only to blaster shots. The spark is a world-mode image: x/y are tile coordinates, so it stays where the shot landed as the camera moves. width/height are pixels. The hit flash in onShot is a screen-mode rectangle that covers the window.
The effects fade in onUpdate, because findimg can't be used from timer callbacks:
ts
// Effects fade out in onUpdate: findimg can't be used from timer callbacks.
export function onUpdate(dt: number) {
for (const fx of this.effects) {
fx.age += dt
const t = Math.min(1, fx.age / fx.life)
const img = findimg(fx.id)
img.alpha = (fx.kind === 'flash' ? 0.35 : 1) * (1 - t)
if (fx.kind === 'spark') img.zoom = 1 + t
if (t >= 1) hideimg(fx.id)
}
this.effects = this.effects.filter((fx: { age: number; life: number }) => fx.age < fx.life)
if (this.effects.length === 0) this.nextImg = 100 // recycle ids
}
export function onActionClientSide(action: string, by: string) {
if (action === 'knockedout')
echo(`You were knocked out by ${by}.`)
}Step 4: a target to shoot
Add an NPC to a level in GRC's level editor, and paste the two target.npc*.ts scripts into its serverside and clientside script tabs.
ts
const MAX_HP = 20
const RESPAWN_SECONDS = 5
export function onCreated() {
this.showCharacter()
this.head = 'head12.png'
this.body = 'body5.png'
this.dir = 2
// Projectiles hit NPC SHAPES (the same hitbox triggerAction uses), in
// pixels from the NPC's top-left. Without a shape nothing can hit it.
this.setShape(0, 0, 32, 32)
reset()
}
function reset() {
this.hp = MAX_HP // unknown properties are per-NPC script state
this.chat = healthBar(this.hp)
}
function healthBar(hp: number): string {
const filled = Math.round(10 * hp / MAX_HP)
return '[' + '#'.repeat(filled) + '-'.repeat(10 - filled) + ']'
}Projectiles hit NPCs by shape, the same hitbox triggerAction uses (setShape, in pixels from the NPC's top-left). An NPC with an image uses the image's size by default. A character NPC has no image, so without setShape shots would fly straight through it.
this.hp isn't a built-in property. Unknown properties on an NPC's this are its own script state, kept between handler calls.
ts
// A projectile hit this NPC's shape. `data` is the shoot call's payload.
// (The clientside script's onShot fires too, on every client.)
export function onShot(data: any) {
if (this.hp <= 0) return
const damage = Math.max(0, Number(data?.damage) || 1)
this.hp = Math.max(0, this.hp - damage)
if (this.hp > 0) {
this.chat = healthBar(this.hp)
return
}
this.chat = `Destroyed by ${String(data?.shooter ?? 'someone')}!`
// NPC timers keep `this` bound to the NPC, and die with it when the
// level reloads.
setTimeout(() => {
this.hp = MAX_HP
this.chat = healthBar(this.hp)
}, RESPAWN_SECONDS * 1000)
}
// A projectile stopped on a wall (or expired) anywhere in this level.
export function onShotAt(x: number, y: number, data: any) {
const close = Math.hypot(x - (this.x + 1), y - (this.y + 1)) < 3
if (data?.weapon === 'blaster' && close && this.hp > 0)
this.chat = 'Missed me!'
}onShot(data)receives the payload fromblaster.ts, so the dummy knows the damage and who fired.- Assigning
this.chaton the server updates the bubble for everyone in the level. - NPC timers keep
thisbound to the NPC and die with it when the level reloads. The respawn can't outlive the NPC. - NPCs also get
onShotAtfor shots that stop anywhere in their level. The dummy taunts you when you miss close by.
Floating damage numbers
ts
// Clientside script of the training dummy (paste into the NPC's clientside
// script in GRC's level editor): floating damage numbers.
interface FloatingNumber { id: number; age: number }
export function onCreated() {
this.numbers = [] as FloatingNumber[]
this.nextImg = 1
}
// Fires on this NPC's clientside script on EVERY client in the level, so
// everyone watching sees the number - no extra network traffic needed.
export function onShot(data: any) {
const img = findimg(this.nextImg)
img.text = `-${Number(data?.damage) || 1}`
img.style = 'bc'
img.fontsize = 14
img.textshadow = true
img.tint = '255,90,90'
img.x = this.x + 1 // world-mode tiles, centered over the NPC
img.y = this.y - 1
this.numbers.push({ id: this.nextImg++, age: 0 })
}
export function onUpdate(dt: number) {
for (const n of this.numbers as FloatingNumber[]) {
n.age += dt
const img = findimg(n.id)
img.y = this.y - 1 - n.age * 1.5 // drift upward
img.alpha = Math.max(0, 1 - n.age)
if (n.age >= 1) hideimg(n.id)
}
this.numbers = (this.numbers as FloatingNumber[]).filter(n => n.age < 1)
if (this.numbers.length === 0) this.nextImg = 1
}The NPC's clientside onShot fires on every client in the level, so every player sees the number without extra network traffic. this.x/this.y are the NPC's position in tiles, which suits a world-mode text image. Clientside NPC images are private to the NPC's script, just like weapon images.
Step 5: damaging players
When a projectile hits a player, only that player's client hears about it, through onShot(data) in its weapon scripts. The blaster reports the hit to the server (the triggerServer('weapon', this.name, 'hit', data.shot) line in step 3's onShot). The server then decides what the hit is worth:
ts
// Player hits are only reported by the HIT player's client (onShot runs
// there). To keep clients from inventing hits, the server remembers every
// shot in flight and applies its OWN damage value, once per shot.
interface Shot { shooterId: number; shooter: string; damage: number; expires: number }
const shots = new Map<number, Shot>()
let nextShot = 1
function registerShot(player: Player): number {
const now = Date.now()
for (const [id, s] of shots)
if (s.expires < now) shots.delete(id)
const id = nextShot++
shots.set(id, { shooterId: player.id, shooter: player.account, damage: DAMAGE, expires: now + LIFETIME * 1000 + 2000 })
return id
}ts
const START_MAX_HP = 10
function hit(victim: Player, shotId: unknown) {
const id = Number(shotId)
const shot = shots.get(id)
if (!shot || shot.expires < Date.now() || shot.shooterId === victim.id) return
shots.delete(id) // each shot damages at most once
// Damage is just a flag write: clientr.hp is server-written and
// client-readable, so any HUD (like the hud tutorial's) shows it live.
const maxhp = Number(victim.clientr.maxhp) || START_MAX_HP
const hp = (Number(victim.clientr.hp) || maxhp) - shot.damage
if (hp > 0) {
victim.clientr.hp = hp
return
}
// Knocked out: refill and send them back to the start.
victim.clientr.hp = maxhp
victim.chat = `*knocked out by ${shot.shooter}*`
victim.warpto(serverOptions.startLevel, serverOptions.startX, serverOptions.startY)
triggerClient('weapon', this.name, 'knockedout', shot.shooter)
}- The server keeps a registry of shots in flight. A hit report must name a real, unexpired shot, each shot counts once, and the damage comes from the server's record, not from the report. That stops a client from inventing hits, for example to "die" on purpose and get a free warp to the start.
- Damage is just a write to
clientr.hp: server-written and client-readable. Any HUD shows it without knowing the blaster exists. - At zero,
warptosends the player toserverOptions.startLevelat the configured start position.
ts
export function onActionServerSide(player: Player, action: string, ...params: any[]) {
if (action === 'fire') fire(player, params[0])
else if (action === 'hit') hit(player, params[0])
}
export function onPlayerLeft(player: Player) {
lastFire.delete(player.id)
}Player hits are client-reported
Because onShot for players runs on the victim's client, a modified client can simply not report a hit. The registry prevents fake hits, but it can't force real ones. The victim also only hears onShot in weapons they have, so give every player the blaster (or a small "health" weapon that handles onShot) through startWeapons.
The complete files
weapons/blaster.ts
ts
// Serverside half of the `blaster` weapon. Projectiles are server-owned:
// only a serverside script can call level.shoot, and the server simulates
// every flight and decides what it hits.
const GANI = 'arrow' // the .gan the projectile renders with
const SPEED = 20 // tiles per second (max 100)
const LIFETIME = 1.5 // seconds (max 60)
const DAMAGE = 2
const COOLDOWN_MS = 300
// Graal-style angles in radians: 0 = right, PI/2 = up.
const ANGLES: Record<string, number> = {
right: 0,
up: Math.PI / 2,
left: Math.PI,
down: 3 * Math.PI / 2,
}
const lastFire = new Map<number, number>()
function fire(player: Player, dir: unknown) {
// The client has a cooldown too, but only this one counts. A little
// slack keeps honest clients from being rejected by timing jitter.
const now = Date.now()
if (now - (lastFire.get(player.id) ?? 0) < COOLDOWN_MS * 0.8) return
lastFire.set(player.id, now)
const angle = ANGLES[String(dir)] ?? 0
// The player's body is 2x2 tiles from (x, y): (x+1, y+1) is its center.
// Spawn 2.5 tiles out so the shot starts clear of the shooter. Screen y
// grows downward, hence the minus on sin.
const x = player.x + 1 + Math.cos(angle) * 2.5
const y = player.y + 1 - Math.sin(angle) * 2.5
const shot = registerShot(player)
// `data` rides along to every callback: onShotAt, onShot (NPC and player).
level.shoot(x, y, GANI, angle, SPEED, LIFETIME,
{ weapon: 'blaster', shot, shooter: player.account, damage: DAMAGE })
}
// Player hits are only reported by the HIT player's client (onShot runs
// there). To keep clients from inventing hits, the server remembers every
// shot in flight and applies its OWN damage value, once per shot.
interface Shot { shooterId: number; shooter: string; damage: number; expires: number }
const shots = new Map<number, Shot>()
let nextShot = 1
function registerShot(player: Player): number {
const now = Date.now()
for (const [id, s] of shots)
if (s.expires < now) shots.delete(id)
const id = nextShot++
shots.set(id, { shooterId: player.id, shooter: player.account, damage: DAMAGE, expires: now + LIFETIME * 1000 + 2000 })
return id
}
const START_MAX_HP = 10
function hit(victim: Player, shotId: unknown) {
const id = Number(shotId)
const shot = shots.get(id)
if (!shot || shot.expires < Date.now() || shot.shooterId === victim.id) return
shots.delete(id) // each shot damages at most once
// Damage is just a flag write: clientr.hp is server-written and
// client-readable, so any HUD (like the hud tutorial's) shows it live.
const maxhp = Number(victim.clientr.maxhp) || START_MAX_HP
const hp = (Number(victim.clientr.hp) || maxhp) - shot.damage
if (hp > 0) {
victim.clientr.hp = hp
return
}
// Knocked out: refill and send them back to the start.
victim.clientr.hp = maxhp
victim.chat = `*knocked out by ${shot.shooter}*`
victim.warpto(serverOptions.startLevel, serverOptions.startX, serverOptions.startY)
triggerClient('weapon', this.name, 'knockedout', shot.shooter)
}
export function onActionServerSide(player: Player, action: string, ...params: any[]) {
if (action === 'fire') fire(player, params[0])
else if (action === 'hit') hit(player, params[0])
}
export function onPlayerLeft(player: Player) {
lastFire.delete(player.id)
}weapons/blaster.client.ts
ts
// Clientside half of the `blaster` weapon: D fires in the direction you
// face. Everything visual about the projectile itself (flight, gani) is
// handled by the engine; this script adds the effects around it.
const COOLDOWN_MS = 300
export function onCreated() {
this.lastFire = 0
this.effects = [] as { id: number; age: number; life: number; kind: 'spark' | 'flash' }[]
this.nextImg = 100
}
export function onKeyPressed(key: string) {
if (key !== 'D') return
// Client-side cooldown: don't spam the server with shots it will reject.
const now = Date.now()
if (now - this.lastFire < COOLDOWN_MS) return
this.lastFire = now
triggerServer('weapon', this.name, 'fire', player.dir)
}
// A projectile stopped on a wall or ran out of lifetime somewhere in this
// level - fires on every client in the level, for everyone's projectiles.
export function onShotAt(x: number, y: number, data: any) {
if (data?.weapon !== 'blaster') return
const spark = findimg(this.nextImg)
spark.x = x - 0.25 // world mode: tiles; (x, y) is the stop point
spark.y = y - 0.25
spark.width = 8 // explicit sizes are pixels
spark.height = 8
spark.tint = '255,200,80'
spark.layer = 4 // above players
this.effects.push({ id: this.nextImg++, age: 0, life: 0.4, kind: 'spark' })
}
// A projectile hit THIS client's player. Only the victim's client hears it,
// so it reports the hit; the server checks the shot id and applies damage.
export function onShot(data: any) {
if (data?.weapon !== 'blaster') return
triggerServer('weapon', this.name, 'hit', data.shot)
const flash = findimg(this.nextImg)
flash.screen = true
flash.x = 0
flash.y = 0
flash.width = ScreenWidth
flash.height = ScreenHeight
flash.tint = '255,0,0'
flash.alpha = 0.35
this.effects.push({ id: this.nextImg++, age: 0, life: 0.3, kind: 'flash' })
}
// Effects fade out in onUpdate: findimg can't be used from timer callbacks.
export function onUpdate(dt: number) {
for (const fx of this.effects) {
fx.age += dt
const t = Math.min(1, fx.age / fx.life)
const img = findimg(fx.id)
img.alpha = (fx.kind === 'flash' ? 0.35 : 1) * (1 - t)
if (fx.kind === 'spark') img.zoom = 1 + t
if (t >= 1) hideimg(fx.id)
}
this.effects = this.effects.filter((fx: { age: number; life: number }) => fx.age < fx.life)
if (this.effects.length === 0) this.nextImg = 100 // recycle ids
}
export function onActionClientSide(action: string, by: string) {
if (action === 'knockedout')
echo(`You were knocked out by ${by}.`)
}npcs/target.npc.ts (serverside NPC script)
ts
// Serverside script of a training-dummy NPC (paste into the NPC's
// serverside script in GRC's level editor). It keeps its health in its own
// per-NPC state and shows it in its chat bubble.
const MAX_HP = 20
const RESPAWN_SECONDS = 5
export function onCreated() {
this.showCharacter()
this.head = 'head12.png'
this.body = 'body5.png'
this.dir = 2
// Projectiles hit NPC SHAPES (the same hitbox triggerAction uses), in
// pixels from the NPC's top-left. Without a shape nothing can hit it.
this.setShape(0, 0, 32, 32)
reset()
}
function reset() {
this.hp = MAX_HP // unknown properties are per-NPC script state
this.chat = healthBar(this.hp)
}
function healthBar(hp: number): string {
const filled = Math.round(10 * hp / MAX_HP)
return '[' + '#'.repeat(filled) + '-'.repeat(10 - filled) + ']'
}
// A projectile hit this NPC's shape. `data` is the shoot call's payload.
// (The clientside script's onShot fires too, on every client.)
export function onShot(data: any) {
if (this.hp <= 0) return
const damage = Math.max(0, Number(data?.damage) || 1)
this.hp = Math.max(0, this.hp - damage)
if (this.hp > 0) {
this.chat = healthBar(this.hp)
return
}
this.chat = `Destroyed by ${String(data?.shooter ?? 'someone')}!`
// NPC timers keep `this` bound to the NPC, and die with it when the
// level reloads.
setTimeout(() => {
this.hp = MAX_HP
this.chat = healthBar(this.hp)
}, RESPAWN_SECONDS * 1000)
}
// A projectile stopped on a wall (or expired) anywhere in this level.
export function onShotAt(x: number, y: number, data: any) {
const close = Math.hypot(x - (this.x + 1), y - (this.y + 1)) < 3
if (data?.weapon === 'blaster' && close && this.hp > 0)
this.chat = 'Missed me!'
}npcs/target.npc.client.ts (clientside NPC script)
ts
// Clientside script of the training dummy (paste into the NPC's clientside
// script in GRC's level editor): floating damage numbers.
interface FloatingNumber { id: number; age: number }
export function onCreated() {
this.numbers = [] as FloatingNumber[]
this.nextImg = 1
}
// Fires on this NPC's clientside script on EVERY client in the level, so
// everyone watching sees the number - no extra network traffic needed.
export function onShot(data: any) {
const img = findimg(this.nextImg)
img.text = `-${Number(data?.damage) || 1}`
img.style = 'bc'
img.fontsize = 14
img.textshadow = true
img.tint = '255,90,90'
img.x = this.x + 1 // world-mode tiles, centered over the NPC
img.y = this.y - 1
this.numbers.push({ id: this.nextImg++, age: 0 })
}
export function onUpdate(dt: number) {
for (const n of this.numbers as FloatingNumber[]) {
n.age += dt
const img = findimg(n.id)
img.y = this.y - 1 - n.age * 1.5 // drift upward
img.alpha = Math.max(0, 1 - n.age)
if (n.age >= 1) hideimg(n.id)
}
this.numbers = (this.numbers as FloatingNumber[]).filter(n => n.age < 1)
if (this.numbers.length === 0) this.nextImg = 1
}Try it
- Create both halves of
blasterin GRC's Weapons editor, upload thearrowanimation (see the warning at the top), and grantblasterto yourself from GRC's Players window (right-click, Grant weapon…). Granthudfrom tutorial 2 as well if you want to see your health. - Place a training dummy NPC in a level and save it.
- Face a wall and press D: a spark flashes where the shot stops.
- Shoot the dummy: red damage numbers float up and its health bar shrinks. Keep shooting until it's destroyed, and it respawns five seconds later. Miss it closely and it taunts you.
- With a second client, shoot the other player. Their screen flashes and their HUD bar drops. After five hits they are knocked out and warped to the start.
Next steps
- Weapon variety: pass a
kindindataand give each kind different speed, damage or a three-way spread (fire three shots atangle - 0.2,angle,angle + 0.2). - Ammo: store
clientr.ammo, deduct it infire, and show it on the HUD. - Kill credit: count knockouts per shooter in a server flag such as
server.knockouts = { [account]: n }and show a top-5 list. Tutorial 8 builds a proper leaderboard. - Firing animation: play a shoot gani with
setAni(...), or swap idle/walk withreplaceAniwhile the blaster is equipped. - Muzzle flash: in
fire, alsolevel.putnpca short-lived local NPC at the spawn point that shows an image and callsthis.destroy()after 100 ms.