Skip to content

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.hp drops (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 the data payload.
  • The four places a projectile can end up, and which callback fires for each: onShotAt, onShot on an NPC, and onShot on 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 happenedCallbackWhere it fires
hit a blocking tileonShotAt(x, y, data)serverside NPCs in that level, and every clientside weapon and NPC script there
lifetime ran outonShotAt(x, y, data)same
hit an NPC's shapeonShot(data)that NPC's serverside script and its clientside script on every client
hit a playeronShot(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: 0 is right and PI/2 is up. Screen y grows downward, which is why the spawn offset subtracts sin(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/y and the projectile are both in gmap-global tiles, and shots fly across member-level seams.
  • speed is tiles per second (max 100) and lifetime is seconds (max 60).
  • level is the current player's level. Like triggerClient, it only works synchronously inside a player-scoped handler, and shoot returns null otherwise. shoot also returns a Projectile whose destroy() 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 from blaster.ts, so the dummy knows the damage and who fired.
  • Assigning this.chat on the server updates the bubble for everyone in the level.
  • NPC timers keep this bound to the NPC and die with it when the level reloads. The respawn can't outlive the NPC.
  • NPCs also get onShotAt for 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, warpto sends the player to serverOptions.startLevel at 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 ​

  1. Create both halves of blaster in GRC's Weapons editor, upload the arrow animation (see the warning at the top), and grant blaster to yourself from GRC's Players window (right-click, Grant weapon…). Grant hud from tutorial 2 as well if you want to see your health.
  2. Place a training dummy NPC in a level and save it.
  3. Face a wall and press D: a spark flashes where the shot stops.
  4. 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.
  5. 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 kind in data and give each kind different speed, damage or a three-way spread (fire three shots at angle - 0.2, angle, angle + 0.2).
  • Ammo: store clientr.ammo, deduct it in fire, 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 with replaceAni while the blaster is equipped.
  • Muzzle flash: in fire, also level.putnpc a short-lived local NPC at the spawn point that shows an image and calls this.destroy() after 100 ms.