Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

Weapon

Weapon arms every player in the world with one weapon, under a name that is unique to it. It mounts once in the scene, not on an entity: the room’s world and each page’s world hold the same weapons, because both mount the same scene.

Its props are in WeaponProps, which are the weapon’s WeaponSettings plus name and the pointer button it fires on, primary by default.

On a page, a click on the canvas fires the weapon from the middle of the player’s character toward what is under the pointer. A press that drags more than a few pixels turns the camera and fires nothing, and so does a click on the page’s own controls over the canvas. While the camera holds the cursor captured, the shot goes through the middle of the screen, where the crosshair is. A downed character fires nothing, nor does a character with the engine’s DisarmedTrait trait, nor a character who does not hold the weapon, nor a click faster than shotsPerSecond.

An instant shot draws its tracer, and a hit marker where the page shows a hit, at once: a larger gold one where it strikes one of the target’s hit zones. The room’s verdict arrives later as the target’s health. When a rule refuses the shot, the room tells the page, and the page takes its hit marker back. The room’s result for the shot names the zone it judged the shot struck, and the marker takes that word, so a gold marker never stands for a body hit. With no room, the page deals the damage or launches the projectile itself, but an instant shot fires only its middle pellet and pierces nothing.

kind picks one of the two WeaponKind values:

  • WeaponKind.Instant, the default, hits at once. The room rewinds each target to where the shooter’s screen showed it as she fired, and judges the hit there. Each pellet hits the nearest target on its ray, plus as many behind it as pierce allows, unless the ground, a wall, a rock or a prop stands nearer. pellets fans that many rays level with the ground, spread radians apart.
  • WeaponKind.Projectile launches a body that the room flies at speed metres a second, drawn as model. It needs no rewind: it hits the first target in its path as the room steps it.

Before it judges a shot, the room runs the shot’s rules. A rule can refuse the shot, and the rewind rule can move the moment the shot is judged at. A shot that passes every rule is judged. A shot passes through the shooter’s ally while friendly fire is off: with no teams(), every other player’s character, and with teams(), her teammates, as Teams says. A weapon’s friendlyFire overrides the world’s setting.

A game with no code gets every rule below, so one player with a modified page cannot ruin a room. A game that wants something else loosens a rule, turns it off, adds its own, or trusts the players’ pages.

Each rule has a name. The room runs them in the following order and stops at the first that refuses:

Rule What it does Why it exists What it costs an honest player
armed Refuses a shot from a downed character, or one with the engine’s DisarmedTrait trait. A page that ignores the downed state fires anyway. Nothing: her own page fires nothing then either.
held Refuses a shot from a weapon her HeldWeaponsTrait leaves out. A character without the trait holds every weapon. A page that fires a gun its player never bought, or one she swapped away. Nothing: her own page fires only what she holds.
rate Holds her to the weapon’s shotsPerSecond, as her stats give it, with a burst of the shots her rate earns in 0.65 s, plus one, and never fewer than two. A page that fires faster than the weapon allows. Nothing up to a 0.65 s network stall: the shots a stall holds back land together, and all count. Past that, the burst’s last shots are refused.
origin Refuses a shot that leaves from more than 2.5 m from the middle of her body in the room. A page that fires from across the map, or from beside its target. Nothing on a network the rest of the room plays on: her page runs her at most about 2.5 m ahead of the room, at a 5 m/s run with 100 ms of travel.
cover Refuses a shot whose origin stands past cover from the middle of her body, as the room’s world stands now. A page that fires from the far side of a wall. Rarely, a shot from a muzzle that pokes past a wall’s edge she stands against.
rewind Judges the shot at the moment her screen showed, held within 200 ms of her measured latency and at most a second back. A moment outside is judged at the nearest edge. Refuses nothing. A page that names whichever past moment put a target on its ray, which is called backtracking. A target is hit where she saw it, up to a second after it moved or reached cover on its own screen. A shot her network delayed more than 200 ms past her usual is judged at the edge.

A weapon the world does not hold is always refused, under the name weapon, since there is nothing to judge.

The room measures each player’s latency from her shots. Each shot names the room’s step her screen showed as she fired, and the room notes how long before its own step that was as the shot lands: her round trip plus the delay her page draws the world at. Her LatencyTrait trait holds the middle of her last eight, her jitter, which is how far they spread round it on average, and the eight themselves. The rewind rule reads it.

So the rewind follows each player’s own connection. A player at 100 ms with 80 ms of jitter is judged where she saw her target, and so is a player at 300 ms. Valve’s Source engine rewinds the same way: by the latency it measures, plus the delay the client draws at, at most sv_maxunlag, a second by default. When a command names a moment more than 200 ms from that, Source judges it at the measured latency. This room judges it at the nearest edge instead, which is closer to what an honest player saw.

Her first shot has no latency to hold it to, so the room judges it at the moment it names, at most a second back. A page cannot see her latency before her first shot, and a check that needs it before then reads no samples.

The rewind stops a page from picking, shot by shot, the past moment that puts a target on its ray: each shot is held within 200 ms of the page’s usual delay. It cannot stop a page that claims a long delay on every shot, because the room measures the delay from the steps the page names. Such a page plays as a player on a slow connection does, and it reaches no further back than the second. Source has the same limit for a client that delays its own packets. A game where that matters tightens maximumSeconds, or adds a rule that reads LatencyTrait, as the example under Write a game’s own rule does.

A game sets its rules with registerShotSettings on its world. rules is a record of rules by name, laid over the engine’s:

  • A rule under an engine rule’s name replaces it, in its place. originRule, rateRule and rewindRule take options that loosen or tighten the engine’s rule.
  • false turns the rule of that name off.
  • A rule under a new name is the game’s own, run after the engine’s.

The scene registers the settings, so the room’s world and every page’s world hold the same. The following scene lets a shot leave from 4 m, rides out a two-second stall, and turns the cover rule off:

import { useWorld } from "koota/react";
import { useEffect } from "react";
import { originRule, rateRule, registerShotSettings } from "@spawnite/engine";
/** Mounted in the scene, so the room's world and every page's register it. */
export function ShotRules() {
const world = useWorld();
useEffect(
() =>
registerShotSettings(world, {
rules: {
origin: originRule({ metres: 4 }),
rate: rateRule({ stallSeconds: 2 }),
cover: false,
},
}),
[world],
);
return null;
}

A weapon’s own rules lie over the game’s the same way, for that weapon alone. A lance whose beam starts at its tip, 3 m ahead of her, loosens the origin rule for itself: <Weapon name="lance" rules={{ origin: originRule({ metres: 3.5 }) }} ... />. A weapon that names a rule the game turned off turns it back on in its place.

rewindRule({ maximumSeconds: 0 }) judges every shot at the room’s present, which favors the target: a shooter on a slow connection has to lead her target. Turning the rewind rule off with rewind: false judges each shot at the moment it names, anywhere in the last second.

A rule is a function of one ShotCheck. It returns the reason it refuses the shot, in words a person reads in the room’s log, or undefined to let it through. The engine’s own rules are written against the same ShotCheck, so a game’s rule can read everything they read:

  • claim: the shot as her page sent it, with its weapon, the step it names, its origin, its direction, and the hits it claims where the page claims any.
  • shooter: her character and its traits, among them LatencyTrait and FiredShotsTrait, her last 16 shots with the moment each named, the moment it was judged at, when it landed and what refused it.
  • weapon: the weapon’s settings, as the world registered them.
  • world: the room’s world as the shot landed. readPastPlace reads where any entity stood at any step in the last second, and readPastTraits reads every trait of it as her screen held them then, such as a game’s own shield. A page takes each send’s traits as it lands and draws the world toward it, so between two sends her screen holds the newer send’s traits, and readPastTraits reads that send, as the room reads a target’s hit zones.
  • landed and now: the room’s step and its clock, in milliseconds, as the shot landed.
  • step: the step the shot is judged at, which a rule may move, as the rewind rule does.

The following rule refuses a shot from inside a safe zone, and a shot from a player further than 400 ms behind the room:

import { LatencyTrait, TransformTrait, type ShotRule } from "@spawnite/engine";
/** Metres round the origin where nobody fires. */
const safeZoneMetres = 10;
export const noShotsFromSafety: ShotRule = ({ shooter }) => {
const feet = shooter.get(TransformTrait)?.position;
if (feet && feet.length() < safeZoneMetres)
return "Fired from inside the safe zone.";
if ((shooter.get(LatencyTrait)?.seconds ?? 0) > 0.4)
return "Fired from more than 400 ms behind the room.";
return undefined;
};

The scene adds it with registerShotSettings(world, { rules: { safeZone: noShotsFromSafety } }). Its refusals carry the name safeZone.

A refusal is never silent. The room does the following for each one:

  • Counts it by the rule’s name in its next heartbeat, as refusedShots, such as { "origin": 2 }. A count that climbs in honest play says a rule is too tight for the game.
  • Logs a shotRefused line with her character, the weapon, the rule and its reason, for up to four of each player’s refusals between two heartbeats.
  • Records it on her character’s FiredShotsTrait, which the next shot’s rules read.
  • Tells her page, where the page numbered the shot, as the engine’s page does. useRoom’s refusedShot holds the last refusal: the shot’s number, which sendShot returned, the rule and the reason. The engine’s Weapon takes its hit marker back. A game that fires from its own trigger compares the number its sendShot returned.

judge: ShotJudge.Page in registerShotSettings makes each player’s page decide what her shots hit, as a Roblox game’s client does by default. The page names the targets each pellet hit on its own screen, and the room deals the weapon’s damage to each, as far as pierce allows. The room still runs the rules, so a game that trusts the page still holds her to her rate and to her body. It also still deals only its own damage: a page names a target, never an amount.

Trust the page in a game where nobody gains by cheating: a party game among friends, or a game whose players play together against the game rather than against each other. The page’s word then counts even where the room’s copy of the world disagrees, such as where a target stood a little further on. The room judges the zone of each target a page names itself, where the target stood on her screen: a zone multiplies the damage, and a page names a target, never an amount.

A weapon sets judge for itself, over the game’s. The engine’s Weapon claims its hits when the page judges it, and so does a room’s bot. A game that fires from its own trigger claims them with claimShotHits and sends them in sendShot’s hits. A room refuses nothing for a claim: it skips a target it does not hold, one the shot spares, one her screen never showed, and one with no health left.

A melee strike on a key with a cooldown is an ability of AbilityKind.Area: it strikes every hostile target within its radius of the caster, and the engine casts it from a key, spends its cost and counts down its cooldown. Write your own only when the strike needs more than that, such as an arc in front of her.

A game that fires its own weapon, such as a melee swing with an arc, a turret or a charge shot, sends a command rather than a shot. It gets the same rewind as an engine weapon. Its page names the step its screen shows with useRoom.getState().readViewStep(), and its room system judges the swing there:

  • rewindStep holds that step to her measured latency and a second back, as the rewind rule holds a shot’s.
  • readPastPlace reads where an entity stood at that step. findRewindSends with readSentPlace reads many at one step for less.
  • measureLatency adds the swing to her latency, for a game whose players fire no engine weapon.

The following system deals a swing’s damage to each monster within 2 m of her where her screen showed it:

import { createQuery, type World } from "koota";
import { Vector3 } from "three";
import {
dealDamage,
defineCommand,
definePlugin,
findCharacter,
HealthTrait,
number,
readCommands,
readPastPlace,
rewindStep,
TransformTrait,
type AuthoritativeStep,
} from "@spawnite/engine";
import { Monster } from "./monsters";
const monsters = createQuery(Monster, HealthTrait, TransformTrait);
const seen = new Vector3();
/** Each swing, which names the step her screen showed. A system on the
* room alone, so its step is the room's, which the damage takes. */
function swingClaws(world: World, room: AuthoritativeStep) {
for (const command of readCommands(room, claws.commands.swing)) {
command.accept();
const character = findCharacter(world, command.player);
const feet = character?.get(TransformTrait)?.position;
if (!character || !feet) continue;
const step = rewindStep(world, character, command.payload.step);
for (const monster of world.query(monsters)) {
const place = readPastPlace(world, monster, step, seen);
if (place && place.distanceTo(feet) <= 2)
dealDamage(room, monster, {
amount: 10,
source: character,
weapon: "claws",
});
}
}
}
export const claws = definePlugin({
name: "claws",
commands: {
swing: defineCommand({ step: number(0, Number.MAX_SAFE_INTEGER) }),
},
systems: {
rules: { swingClaws: { system: swingClaws, answers: ["swing"] } },
},
});

The game lists claws in its plugins, which registers the claws.swing command. The page sends a swing with sendCommand(world, claws.commands.swing, { step }), with step from readViewStep(). The field is a number, not an integer: a view step falls between two of the room’s steps.

The rules cost the room nothing it can measure next to judging the shot. Judging a shot costs about as much as it did before the rules, and trusting the page costs a fifth less. The rewind history grew from 200 ms of the stream’s sends to a second of them, which a development room’s checkpoints carry. The following numbers were measured on one development machine, each pair in the same minute:

Measure The room judges, with the rules The page judges Before the rules
One shot of three pellets among 4 characters and 40 monsters 54 to 58 µs 45 µs 52 to 55 µs
The same shot with every rule off 61 to 64 µs
Holdfast’s room with 4 bots for 60 s: room CPU a second 45 ms 54 ms
Holdfast’s room with 4 bots for 60 s: memory 268 MB 251 MB
The rewind history in a Holdfast checkpoint 199 KB of JSON, 21 sends the same 5 sends
A Holdfast development room’s longest checkpoint, with 4 bots 18 ms the same 10 ms

The shot’s numbers move with the machine’s load: a quieter run measured 29 to 33 µs with the rules and 34 µs before them. The rules-off row is within that noise of the rules-on row. Holdfast’s bots fire about 90 shots a minute between them, so judging costs its room about 80 µs a second. The difference between the two Holdfast CPU rows is how the two runs played, not the judge. The history keeps each send’s dump, which the stream made anyway, so it costs no copy in a room. A development room writes it into each checkpoint, which takes longer as the world grows; a deployed room keeps no recording.

Every registered weapon arms every player until the game says otherwise. A game whose players buy, pick up or swap weapons puts the engine’s HeldWeaponsTrait trait on each character: the names of the weapons she may fire now. The room refuses a shot from any other under the held rule, and neither the engine’s Weapon nor a room’s bot fires one. holdsWeapon answers the same question for a game’s own trigger.

The trait reads as follows:

  • No trait: she holds every registered weapon. A game with one weapon, or one that never sets the trait, needs no change.
  • A list: she holds the weapons it names, and no others. Two names arm two buttons at once, as a Holdfast warden’s gun and lance do.
  • An empty list: she holds nothing. DisarmedTrait stops every shot too, but a game that takes a gun away keeps DisarmedTrait for a stun or a safe zone.

The room decides what she holds, because a shot’s consequence is the room’s. A room system writes the whole list, the trait streams to every page, and each page reads what she holds from its copy of her character. A page never writes the trait: a “buy” or “swap” command asks, and the room’s system checks her wallet or her rack before it changes the list.

The scene’s <CharacterSpawn> names the weapons she starts with, as Roblox’s StarterPack copies its tools into each player’s backpack and Unreal gives a pawn its default inventory: <CharacterSpawn weapons={["pistol"]} />. The room puts HeldWeaponsTrait on each character as it spawns her, so a page that fires before the game’s own systems first reach her fires only those. A <CharacterSpawn> that names none leaves her holding every weapon until a system sets her list.

The following room system runs a gun rack where she holds one gun at a time. Each character starts with the pistol <CharacterSpawn> names, bought and held. On each “swap” command, the system swaps her gun to one she has already bought, and refuses not-bought for one she has not:

import { createQuery, Not, trait, type World } from "koota";
import {
defineCommand,
definePlugin,
ControlledTrait,
findCharacter,
HeldWeaponsTrait,
oneOf,
readCommands,
type AuthoritativeStep,
} from "@spawnite/engine";
/** The guns she has bought, which stay hers when she swaps. */
export const RackTrait = trait((): string[] => []);
const newCharacters = createQuery(ControlledTrait, Not(RackTrait));
function swapGuns(world: World, step: AuthoritativeStep) {
for (const character of world.query(newCharacters))
character.add(RackTrait(["pistol"]));
for (const command of readCommands(step, rack.commands.swap)) {
const { gun } = command.payload;
const character = findCharacter(world, command.player);
if (!character?.get(RackTrait)?.includes(gun)) {
command.refuse("not-bought");
continue;
}
character.set(HeldWeaponsTrait, [gun]);
command.accept();
}
}
export const rack = definePlugin({
name: "rack",
commands: {
swap: defineCommand(
{ gun: oneOf(["pistol", "shotgun"]) },
{ refusals: ["not-bought"] },
),
},
systems: { rules: { swapGuns: { system: swapGuns, answers: ["swap"] } } },
});

The game lists rack in its plugins, which registers the rack.swap command at the default four a second. Once a buy has put a shotgun in her RackTrait, the page swaps to it with sendCommand(world, rack.commands.swap, { gun: "shotgun" }).

Her stats stay on her through the swap, so every card she took raises the new gun as it raised the old one, as Numbers from the shooter’s stats says. A gun’s element is its data, which each of its hits carries.

A game that trusts its players turns the rule off with rules: { held: false } in registerShotSettings, as it turns off any rule. It still sets HeldWeaponsTrait, so the engine’s Weapon fires only the held gun.

The other engines split what a player owns from what she holds:

  • Unreal’s Lyra sample keeps owned items in an inventory component and equips weapons through an equipment component. Equipping a weapon grants its fire ability, and the server runs only an ability the character was granted.
  • Roblox keeps a player’s tools in her Backpack and moves the equipped tool into her character. Tool.Activated fires only for the equipped tool, and a careful game’s server checks the tool’s parent before it acts on a remote event.
  • Unity, Godot and PlayCanvas leave it to the game.

HeldWeaponsTrait is the second half alone: what she may fire now. What she owns, such as a rack, a bag or a loadout, stays the game’s, because games shape it too differently for one engine answer. The trait holds a list rather than one equipped weapon, because a weapon here is bound to a button, and a game fires two buttons at once. With no trait she holds everything, where the others start with nothing equipped, so the common path stays one <Weapon> and no code.

A weapon’s numbers are its own base. stats names, for each number, the shooter’s stats that raise it, as a WeaponStats. The room lays each named stat on the weapon’s own number by the stats formula: the stat’s base and its flat modifiers add to the number, and its percent and more modifiers scale it. So one card raises every weapon that names its stat, each from that weapon’s own base, and a player who swaps guns keeps every card, because the modifiers live on her and not on the gun.

A rifle on the primary button and a rail on the secondary, both naming damage:

import { PointerButton, Weapon, type WeaponStats } from "@spawnite/engine";
const gunStats: WeaponStats = { damage: "damage", shotsPerSecond: "fireRate" };
export function Guns() {
return (
<>
<Weapon
name="rifle"
damage={10}
range={40}
shotsPerSecond={5}
stats={gunStats}
/>
<Weapon
name="rail"
damage={40}
range={60}
shotsPerSecond={0.75}
stats={gunStats}
button={PointerButton.Secondary}
/>
</>
);
}

A card that adds { percent: 0.25 } to her damage stat makes the rifle hit for 12.5 and the rail for 50. A game whose players each hold one gun at a time registers every gun with registerWeapon and names the one she holds in HeldWeaponsTrait. Holdfast registers the three guns of its rack and its lance the same way: a warden holds one gun on the left button, and the lance on the right once a card gives it. Her stats stay on her through a swap.

The following rules hold for every weapon:

  • A number the weapon names no stat for stays its own, whatever her stats. A rail that should not take a pellet card leaves pellets out.
  • A list lays several stats on one number: one every gun shares, and one for this gun alone, such as the stat its own tiers raise: damage: ["damage", "scattergunDamage"]. A gun’s perk, such as a ricochet, is the game’s own system reading the gun’s hits.
  • The shooter’s stat need not be declared; one she lacks adds nothing. Its min and max are not read, since they are in no one weapon’s units.
  • pellets and pierce are whole counts, rounded, with at least one pellet. No number falls below zero.
  • pellets, spread and pierce shape an instant weapon’s pull. A projectile hits the first target in its path, so they change nothing for it. Extra pellets on a weapon with no spread fire down one line, each hitting the same target.
  • speed is a projectile’s alone: its own speed, or 20 m/s where it names none, raised by the stats it names, such as speed: "velocity" for a card that makes a grenade fly faster. An instant shot has no flight, so it changes nothing there.
  • zoneDamage multiplies the multiplier of the hit zone a hit strikes: its own zoneDamage, or 1 where it names none, raised by the stats it names, such as zoneDamage: "headshots" for a card that makes every weak-spot hit deal a quarter more. A hit on the body deals the weapon’s damage whatever it is.

Holdfast’s guns and lance share each warden’s damage and fire-rate stats, and each gun of its rack also names gun stats that only its upgrade tiers raise. It registers its weapons with registerWeapon in its Arsenal, since it fires them from its own trigger. Its room system hands each warden the gun she bought, and the lance once she takes its card.

Unreal’s Gameplay Ability System keeps the attributes on the character and the weapon’s base damage in its ability or data asset, and a damage calculation combines the two. Roblox games keep each weapon’s numbers in a module and multiply them by the player’s buffs. Unity, Godot and PlayCanvas leave it to the game. Games such as Brotato, Risk of Rain 2 and Call of Duty Zombies apply a player’s damage and fire-rate bonuses to whichever weapon she holds. Weapon follows the same split: the base on the weapon, the bonuses on the player, combined by the formula her other stats already use.

An entity declares the parts of it a shot strikes for more or less damage, such as a head or a glowing core, with the engine’s HitZonesTrait trait. Each HitZone is a sphere round at, or a capsule from at to to, with a radius, a name its hits carry and a multiplier on the weapon’s damage. A monster’s prefab declares its head in one line:

packages/engine/test/outside/ghoul.ts
import { definePrefab, HealthTrait, HitZonesTrait } from "@spawnite/engine";
export const ghoul = definePrefab("ghoul", {
traits: [
HealthTrait({ current: 30, maximum: 30 }),
HitZonesTrait([
{
name: "head",
multiplier: 2,
at: { x: 0, y: 1.4, z: -0.2 },
radius: 0.25,
},
]),
],
});

A point is in the entity’s own frame: metres from its feet, with its front along -z, the way an entity faces at rest. The zones turn with its TransformTrait.rotation. A point is an { x, y, z } record or a Vector3.

A shot’s ray hits the entity where it meets its body or one of its zones, whichever it meets first, so a head that pokes out over the body’s capsule is hit. The hit takes the name of the first zone the ray meets anywhere along its line, even behind the body’s front, and deals the weapon’s damage times that zone’s multiplier, times the weapon’s zoneDamage as the shooter’s stats raise it. A ray that meets no zone hits the body at the weapon’s damage.

The zone says how much more a part takes, and the weapon says how much more it makes of that, so a monster’s head deals double to every gun and a sniper’s zoneDamage: 1.5 makes it triple, all in one hit whose number shows the whole of it. Destiny splits it the same way, a precision multiplier per weapon on the enemy’s weak spot, as Borderlands lays a critical-damage stat on its guns’ critical hits. Every kind of hit judges zones alike: the room’s judgement of an instant shot, rewound to where the target stood and faced on her screen; each target a trusted page names; a projectile’s hit; and a page with no room.

The stream carries the zones, so the room and every page test the same shapes against the same place and turn, and the page’s marker agrees with the room’s verdict. The zone reaches the game as the hit’s zone, and each target in a ShotResult carries it too, with the page’s number for the shot, which sendShot returned.

The room runs no animation, so a zone does not follow a bone. Size each zone to hold its part through the clips the entity plays: its walk, its strike and its flinch. A capsule along the line a head sways on keeps a zone close to the head where a sphere round the whole sway would reach past it. <World hitZones> draws every zone over its model to size them against it, and on a development page window.__GAME_HIT_ZONES__(true) turns them on, which spawnite play eval reaches.

The other engines judge a zone against an animated body:

  • Source’s server poses a character’s bones from its stored animation state for each rewound trace, and each hitbox names a hitgroup, a head with four times the damage among them.
  • Unreal’s hit result names the bone of the physics asset’s body it struck. A dedicated server leaves the pose alone unless a game ticks it, so shooters built on Unreal commonly let the client trace and have the server check the hit loosely.
  • Unity, Godot and PlayCanvas leave zones to the game, with a collider per part; Photon Fusion’s hitboxes follow animation only where the game poses them before its capture.
  • Roblox’s raycast returns the part it hit, such as Head.

A zone here is fixed in the entity’s frame instead, as a platform whose server knows only a reference pose does. The room runs the same test as the page, costs nothing past the zones of a target a ray reaches, and a replay continues it exactly. The price is that a zone sized for a whole clip is a little larger than the part at any one moment.

Each target a shot hits gets a Hit in its DamagedEvent event for that step: one for each pellet, for an instant shot and a projectile alike. Two players’ hits on one monster in one step are two entries in the one event, in the order the room dealt them. A hit carries the following:

  • amount: the health it was dealt.
  • source: the shooter’s character.
  • weapon: the weapon’s name.
  • zone: the name of the hit zone it struck, its multiplier already in amount; null for the body.
  • data: the weapon’s data, the game’s own record for each of its hits, such as a pellet’s dose of an element. null for a weapon with none.

data is the same for every hit of the weapon. What depends on the shooter, such as her element, a game reads off source. A game types data once by adding hitData to the engine’s Register, as it types its levels; without it, data is any record:

src/elements.ts
export enum Element {
Storm = "storm",
Frost = "frost",
}
declare module "@spawnite/engine" {
interface Register {
hitData: { element: Element; dose: number };
}
}

From then on a weapon’s data, dealDamage’s data and each hit’s data take only that shape. A system in the room reads the hits, since a hit’s consequence is the room’s:

src/chill.ts
import { trait, type World } from "koota";
import { DamagedEvent, readEvents, type SystemStep } from "@spawnite/engine";
import { Element } from "./elements";
import { Monster } from "./monsters";
export const ChillTrait = trait({ amount: 0 });
/** Adds each Frost hit's dose to the monster it struck. */
export function chillMonsters(_world: World, step: SystemStep) {
for (const { entity: monster, record: hit } of readEvents(
step,
DamagedEvent,
)) {
if (!monster?.has(Monster) || hit.data?.element !== Element.Frost)
continue;
const dose = hit.data.dose;
if (!monster.has(ChillTrait)) monster.add(ChillTrait);
monster.set(ChillTrait, (chill) => ({ amount: chill.amount + dose }));
}
}

A game’s own damage, such as a chain of lightning, carries the same fields: dealDamage(step, monster, { amount, source, weapon: "chain", data }).

In a room, every page hears each hit in the delta after it lands, each source as its own copy of the shooter’s character, one entry per hit. On the binary stream, with a two-field data, a hit weighs about 34 bytes, measured with an element’s name and a dose as data, which is about 6 of those. So four players landing 120 hits a second between them add at most about 4 KB a second to each page’s stream; longer data weighs more.

data holds numbers, strings, booleans, lists and records of them, and reaches a page as the same JSON the room held: a colour as [1, 0, 0] stays that list. ShotJudgedEvent stays the stream a tracer draws: where each instant shot went. It is an event on the world, one event a shot, so a view hears each shot once with useEvent. A projectile draws as its own entity and adds no result there.

Unreal’s ApplyDamage names an instigator, a damage causer and a damage type class, and its Gameplay Ability System carries each hit as an effect spec with a context the game extends. A hit here holds the same three: source is the instigator, weapon the causer, and data the context. Roblox’s Humanoid:TakeDamage takes the amount alone, and games tag the attacker on the humanoid themselves; Unity, Godot and PlayCanvas leave damage to the game.

Both. The room holds every shot to the weapon, and the page fires it and draws the shot. The arena’s scene carries a rifle on the primary button and a sling on the secondary:

import { PointerButton, Weapon, WeaponKind } from "@spawnite/engine";
export function Armoury() {
return (
<>
<Weapon name="rifle" damage={10} range={40} shotsPerSecond={4} />
<Weapon
name="sling"
kind={WeaponKind.Projectile}
damage={25}
range={30}
shotsPerSecond={1}
speed={20}
model="stone"
button={PointerButton.Secondary}
/>
</>
);
}