Appearance
Levels & gmaps
A level is one .glvl file: a rectangular grid of tiles with its own collision map, NPCs and links. A gmap is a .gmap file that stitches a grid of same-sized levels together into one seamless world. This page covers how scripts read and change levels, how coordinates work on both, and the traps where the two differ.
Levels are authored in GRC's Level Editor (and gmaps in its gmap editor); scripts mostly read them, and can paint tiles or create blank levels at runtime through a small server-authoritative API.
What's in a level
| Part | What it is | Editable from scripts? |
|---|---|---|
| Tile layers | One or more grids of tile ids. Layer 0 is the ground; higher layers draw on top. | Yes — setleveltile (server) |
| Collision layer | One collision type per tile (1 = walkable, 22 = blocking, …). | No — paint it in the Level Editor |
| NPCs | Baked NPCs with a server and a client script half. | Via their own scripts; see NPCs |
| Links | Rectangles that warp a player who walks into them. | No — draw them with the editor's Link tool |
A tile id is a row-major index into the tileset image (pics1.png, 16×16-pixel tiles): id = ty * tilesetWidthInTiles + tx. For the default 128-tile-wide tileset, the tile at column 3, row 2 is 2 * 128 + 3 = 259. The id 65535 means empty — nothing is drawn on that layer there.
Collision types follow Graal's numbering (11 water, 12 lava, 20 throw-through, 21 jump stone, 22 blocking, …) and are stored as authored, but the engine currently only gives 22 a built-in meaning: it blocks movement. The others are readable with tiletype so your scripts can give them meaning (e.g. damage a player standing on type 12).
Coordinate units
Everything in the level API is in tiles, and fractions are allowed.
- One tile is 16 pixels. Tile
tstarts at world pixelt * 16. - A player is 2×2 tiles;
player.x/player.yis the top-left corner, so the center is(player.x + 1, player.y + 1). - A player's walking footprint — what collides with walls — is only the lower body: the 2×1 rectangle at
(player.x, player.y + 1). - On a gmap, positions are gmap-global tiles (see Gmaps).
Screen pixels (mouse position, GUI, screen-space images) are a different space; converting between them is covered in Camera, input & movement.
Which level am I in?
| Side | Expression | Gives you |
|---|---|---|
| Server, any player handler | player.level | A ServerLevel object (or null before their first level) |
| Server, inside a player-scoped handler | the level global | The triggering player's level |
| Serverside NPC script | this.level | The NPC's own NpcLevel |
| Client | player.level | The level (or gmap) name, a string |
ServerLevel and the level global expose the level's name plus onwall, tiletype, shoot and putnpc. The difference is binding:
player.levelis bound to that level by name. You can keep it and use it later — in a timer, after anawait— and it still works.levelmeans "the current player's level", and a current player only exists synchronously inside a player-scoped handler (onActionServerSide,onPlayerJoined, …). In a timer callback or after anawait,level.nameis''and its methods returnnull.
ts
// server weapon half
export function onActionServerSide(player: Player, action: string) {
const lvl = player.level // bound by name: safe to keep
if (!lvl) return
setTimeout(() => {
// `level.shoot(...)` would return null here — no current player.
lvl.shoot(player.x + 1, player.y + 1, 'arrow', 0, 10, 3)
}, 1000)
}NPC scripts: always this.level
Inside a serverside NPC script a bare level silently resolves to the player-scoped global above, not to the NPC's level. Use this.level — it's the NPC's own level and accepts member-local coordinates on gmaps.
Reading tiles and collision
Clientside
The client knows the level it is currently in, and reads it directly (these are direct calls — they return live values, safe to use every frame):
- gettile(layer, x, y) — the tile id on a layer,
65535for empty,-1when out of range or no level is loaded. - tiletype(x, y) — the collision type of the tile containing
(x, y), or-1. - onwall(x, y, w?, h?) —
truewhen a blocking tile intersects the rectangle[x, x+w) × [y, y+h). Omitw/hto test one tile. Tiles outside the level count as blocked, and so do blocking NPCs.
ts
// weapon client half: is the player standing in lava?
export function onUpdate() {
if (tiletype(player.x + 1, player.y + 1.5) === 12)
triggerServer('weapon', this.name, 'burn')
}
// Would the player collide one tile to the right?
const blocked = onwall(player.x + 1, player.y + 1, 2, 1)The (player.x, player.y + 1, 2, 1) rectangle is the walking footprint; custom movement code should test exactly that (see custom movement).
Serverside
ServerLevel.onwall / tiletype and the NPC equivalents NpcLevel.onwall / tiletype have the same shape. What counts as "blocking" differs slightly:
| Query | Blocking tiles | Blocking NPCs | Players |
|---|---|---|---|
Client onwall | yes | yes | no |
ServerLevel.onwall (player.level) | yes | yes | no |
NpcLevel.onwall (this.level in an NPC) | yes | yes (never the calling NPC itself) | yes — their lower bodies |
The NPC version counts players so a walking NPC doesn't step onto someone. Whether an NPC blocks at all is controlled by dontblock / blockagain.
TIP
There is no clientside setleveltile, and no API that reads another level's tiles — the client only has the level it's standing in, and the server queries go through a level object.
Changing tiles at runtime
Tile painting is server-authoritative. A client that wants to paint sends the request with triggerServer; the server calls setleveltile, and the change comes back to every player in the level (the painter included) as a tile update:
ts
// weapon server half
export function onActionServerSide(player: Player, action: string, x: number, y: number, tile: number) {
if (action !== 'paint' || !player.level) return
setleveltile(player.level.name, 0, Math.trunc(x), Math.trunc(y), Math.trunc(tile))
}setleveltile(levelName, layer, x, y, tileId) returns false for unknown levels or out-of-range arguments (including a layer the level doesn't have). Repainting a tile with the id it already has is a no-op and isn't broadcast.
The edit lives only in the server's in-memory copy of the level. It is written to the .glvl file when someone saves the level:
- Clientside, updatelevel() asks the server to save the level the player is standing in.
updatelevel is tied to the tileeditor weapon
The server only honours the save request from players who have the tileeditor weapon; for anyone else it is silently ignored. It also saves by the player's current level name, so it does nothing while the player is on a gmap (see below). Treat it as the tile editor's save button rather than a general-purpose API.
Unsaved edits are lost on server restart. If someone uploads a new version of the .glvl from GRC in the meantime, the uploaded file wins and the unsaved in-game edits are discarded.
Creating levels
createlevel(name, fillTile, width?, height?) makes a blank <name>.glvl, filled with fillTile on layer 0 (use 65535 for empty). Rules:
nameis 1-32 letters, digits,_or-, without the extension.- Sizes are 8-512 tiles per side; the default is 64×64.
- It fails if any level — saved or not — already uses the name.
- The new level has one tile layer and all-walkable collision.
It returns '' on success or a player-presentable error message, and the level can be warped into immediately. Like tile edits, it only reaches disk when saved; unsaved levels vanish on restart.
ts
const error = createlevel('arena', 0, 32, 32)
if (error === '')
player.warpto('arena.glvl', 16, 16)
else
triggerClient('weapon', this.name, 'error', error)Moving between levels
Only the server moves players between levels: player.warpto(level, x, y) (returns false when the level doesn't exist). There is no clientside warp — a client that wants to travel asks its server half via triggerServer.
Levels can also carry links (drawn with the Level Editor's Link tool): when a player's feet enter a link rectangle the server validates it and warps them to the link's destination, optionally keeping the X or Y coordinate. Links need no scripting.
When a player changes level, the client keeps showing the old level until the new one — its tiles, tileset, and on a gmap the surrounding cells — has fully downloaded, then swaps. Clients cache recently visited levels and only re-download a level whose bytes have changed.
Hot reload
Saving a .glvl from GRC's Level Editor reloads the level for everyone in it: players stay where they are (clamped if the level shrank), and its NPCs are re-created, so their onCreated runs again.
Gmaps
A gmap arranges member levels in a grid (created and resized in GRC's gmap editor). All members must have the same dimensions, and every cell must be filled. Players walk across member seams continuously, and scripts see a single world:
- While a player is on a gmap, their level is the gmap:
player.levelnames the.gmapfile (e.g.world.gmap), andplayer.x/player.yare gmap-global tiles. - Clientside,
gettile,tiletypeandonwallall take gmap-global coordinates too. - Warping to a member level (
player.warpto('world-b1.glvl', 10, 10)) automatically translates onto the gmap; you don't need to convert first. - One-tile edge links between neighbouring members (from levels that were designed stand-alone) are ignored on a gmap, so walking across a seam never warp-loops. Interior links still work.
- Clients stream a window of member levels around the player (5×5 by default; the
gmapLoadDistanceserver option), and players see each other within a smaller window (gmapPlayerDistance, 3×3 by default).
Converting coordinates
When you need to know which member a gmap position is in — or where a member-local position lands on the gmap — convert explicitly:
- gmaptolevel(gmap, gx, gy) →
{ level, x, y }: the member level under a gmap-global point, and the member-local coordinates.nullif the gmap doesn't exist or the point is off the grid. - leveltogmap(level, x, y) →
{ gmap, x, y }: the reverse.nullwhen the level belongs to no gmap.
ts
// server: which member level is this player standing in?
const where = gmaptolevel(player.level!.name, player.x, player.y)
if (where) echo(`${player.account} is in ${where.level} at ${where.x},${where.y}`)Member file names don't necessarily map to grid positions the way you'd guess — check with leveltogmap rather than assuming an origin.
Both functions exist clientside with the same shapes (gmaptolevel, leveltogmap), but the client only knows its current gmap: pass '' (or the current gmap's name) as gmapName, and leveltogmap only resolves members of the current gmap. Anything else — or not being on a gmap — returns null.
Tile edits on a gmap
setleveltile works on member levels only, with member-local coordinates. Painting on a gmap therefore goes through gmaptolevel first:
ts
export function onActionServerSide(player: Player, action: string, gx: number, gy: number, tile: number) {
const lvl = player.level
if (action !== 'paint' || !lvl) return
let name = lvl.name, x = gx, y = gy
if (name.endsWith('.gmap')) {
const m = gmaptolevel(name, gx, gy)
if (!m) return
name = m.level; x = m.x; y = m.y
}
setleveltile(name, 0, Math.floor(x), Math.floor(y), tile)
}Everyone viewing that member — on the gmap or in the member level directly — receives the change.
Other gmap-aware APIs
- ServerLevel from
player.levelon a gmap is bound to the gmap: itsonwall,shootandputnpctake gmap-global coordinates (putnpcdrops the NPC into the member under the point). - An NPC's this.level is its member level: coordinates are member-local.
- Projectiles fly in gmap-global space and cross seams; see Projectiles.
See also
- NPCs — the scripts baked into levels,
putnpc, blocking - Camera, input & movement — screen vs world space, custom movement
- Players —
player.warpto, positions - Reference: server globals, client globals