Skip to content

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 ​

PartWhat it isEditable from scripts?
Tile layersOne or more grids of tile ids. Layer 0 is the ground; higher layers draw on top.Yes — setleveltile (server)
Collision layerOne collision type per tile (1 = walkable, 22 = blocking, …).No — paint it in the Level Editor
NPCsBaked NPCs with a server and a client script half.Via their own scripts; see NPCs
LinksRectangles 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 t starts at world pixel t * 16.
  • A player is 2×2 tiles; player.x/player.y is 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? ​

SideExpressionGives you
Server, any player handlerplayer.levelA ServerLevel object (or null before their first level)
Server, inside a player-scoped handlerthe level globalThe triggering player's level
Serverside NPC scriptthis.levelThe NPC's own NpcLevel
Clientplayer.levelThe 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.level is bound to that level by name. You can keep it and use it later — in a timer, after an await — and it still works.
  • level means "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 an await, level.name is '' and its methods return null.
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, 65535 for empty, -1 when 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?) — true when a blocking tile intersects the rectangle [x, x+w) × [y, y+h). Omit w/h to 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:

QueryBlocking tilesBlocking NPCsPlayers
Client onwallyesyesno
ServerLevel.onwall (player.level)yesyesno
NpcLevel.onwall (this.level in an NPC)yesyes (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:

  • name is 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.level names the .gmap file (e.g. world.gmap), and player.x/player.y are gmap-global tiles.
  • Clientside, gettile, tiletype and onwall all 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 gmapLoadDistance server 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. null if the gmap doesn't exist or the point is off the grid.
  • leveltogmap(level, x, y) → { gmap, x, y }: the reverse. null when 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.level on a gmap is bound to the gmap: its onwall, shoot and putnpc take gmap-global coordinates (putnpc drops 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 ​