Skip to content
Docs

Case study: Circuito Luz Verde

A commented walk through the Green Games reference game: the idea, the manifest, the scene, the script part by part with real excerpts, the bot script and what tests each thing.

DescribesFormat 1, API 1 For those who write code

On this page

Circuito Luz Verde ("Green Light Circuit") is the Green Games reference game: an obstacle race against the clock, made to use every service of the API in little code. This page reads it from the inside. You can play it at https://play.green.games.

The excerpts are copied from the game's real files, which are written in Spanish: names, ids and comments stay as they are. The console output near the end is also real and is shown as the tool prints it, in Spanish.

FileSize
juego.jsonThe manifest, with 32 texts per language
escenas/principal.json109 lines: 79 pieces, 5 spawn points, 7 zones, 4 lights
scripts/principal.ts340 lines
pruebas/bot.json34 steps
The resulting .ggx9.0 KB

The idea in one sentence#

Run to the goal as fast as you can; you win by arriving, you lose if time runs out; the score is your time, minus one second for each coin.

The whole design comes from there: a table of times (the lowest wins), a limit per difficulty and coins that tempt you to take a detour.

The manifest#

What it declares and why (games/circuito-luz-verde/juego.json):

juego.json (excerpt)
	"jugadores": {
		"min": 1,
		"max": 4
	},
	"controles": {
		"teclado": true,
		"tactil": true,
		"mando": true,
		"acciones": {
			"saltar": {
				"es": "Saltar",
				"en": "Jump"
			},
			"accion": {
				"es": "Llamar el ascensor",
				"en": "Call the lift"
			}
		}
	},
  • jugadores.max: 4 allows playing with up to three bots. The script is written for a list of players, not for one.
  • Two actions. saltar and accion. The text of accion says what it does in this game, "Call the lift", and that is what the player reads in the controls table. accion2 is not declared, so it has no touch button and does not appear in the help.
juego.json (excerpt)
	"puntajes": [
		{
			"id": "tiempo",
			"nombre": {
				"es": "Mejores tiempos",
				"en": "Best times"
			},
			"orden": "menor",
			"formato": "tiempo"
		}
	],
  • One table, of times. "orden": "menor" because the lowest time wins, and "formato": "tiempo" so that it is shown as 1:23.45.
  • Three difficulties (facil, normal, dificil) with normal as the initial one.
  • All the texts are in textos, in Spanish and in English, with variables: "monedas": "{n} de {total}", "sinTiempo": "Se acabó el tiempo. Llegaste al control {n} de {total}.".

The scene#

A single file, escenas/principal.json. The course advances along +Z, from z = 0 to z = 131, in seven stretches:

StretchzWhat is thereWhat it teaches
Start0 to 5A platform, a three-light traffic light and a barrierPieces with an id that the script lights up and moves
Slabs9 to 22Four slabs over the voidDifferent pieces per difficulty
Moving platform32 to 38A platform that goes back and forthmovimiento declared in the scene
Trampoline43A neon disc that launches you to the upper levelA tag plus jugador.impulsar
Sweeper63A plate with an arm that spinsA piece moved by the script every frame, with datos
Corridor74 to 106A hallway with a traffic light and three parapetsZone, ray and tags that change
Elevator and goal115 to 131Loose boxes, an elevator and the goal archDynamic pieces, player action, goal zone

Difficulties in the scene#

Each slab exists twice in the same place: wide for facil and normal, narrow for dificil. And on facil there are, in addition, wooden bridges between one slab and the next.

escenas/principal.json (excerpt)
		{ "id": "losa-1", "forma": "caja", "posicion": [0, -0.5, 9], "tamano": [3.4, 1, 3], "material": { "color": "verde", "patron": "rejilla" }, "dificultades": ["facil", "normal"] },
		{ "id": "losa-1-d", "forma": "caja", "posicion": [0, -0.5, 9], "tamano": [2.2, 1, 2.2], "material": { "color": "verde", "patron": "rejilla" }, "dificultades": ["dificil"] },
escenas/principal.json (excerpt)
		{ "forma": "caja", "posicion": [0, -0.35, 6.75], "tamano": [1.6, 0.5, 1.7], "material": "madera", "dificultades": ["facil"], "etiquetas": ["puente"] },

The room loads only the pieces of the chosen difficulty: the script does not have to hide or create anything.

Tags and ids#

  • With an id: what the script or the bot names one by one. barrera, brazo, ascensor, semaforo-corredor, luz-control-1.
  • With a tag: what is handled as a group. peligro (the arm), trampolin, banda (the two light strips of the corridor), and control on the four checkpoint zones.
  • With nothing: posts, decoration and the blocks that float at the sides, all with "fisica": "ninguna".

The arm of the sweeper carries in datos what the script needs to move it, instead of having those numbers written in the code:

escenas/principal.json (excerpt)
		{ "id": "brazo", "forma": "caja", "posicion": [2.4, 4.75, 63.5], "tamano": [4, 0.7, 0.5], "material": { "color": "coral", "acabado": "neon" }, "etiquetas": ["peligro"], "datos": { "centro": [0, 4.75, 63.5], "radio": 2.4 } },

Checkpoints#

Each checkpoint is three things with names that match: a spawn point control-1, a zone zona-control-1 with the tag control, and a sphere luz-control-1 that the script lights up. The coins are not in the scene: the script creates them.

The first seconds of a demo match. The light blue platform is the piece movil-1; the green disc, the trampoline. At the top, the HUD the script paints: coins, stopwatch and checkpoint. The on-screen text is in Spanish.

The script, part by part#

scripts/principal.ts has 340 lines. The excerpts that follow are copied from the file; // ... marks what was cut.

Settings per difficulty#

scripts/principal.ts (lines 5 to 11)
// --- Ajustes por dificultad ----------------------------------------------------------------------------------------
// juego.nivelDificultad es el indice de la dificultad elegida en juego.json: 0 facil, 1 normal, 2 dificil.
const AJUSTES = [
	{ limite: 300, giro: 50, verde: 3.2, ambar: 0.9, roja: 1.5, monedas: 24 },
	{ limite: 240, giro: 72, verde: 2.6, ambar: 0.8, roja: 1.8, monedas: 20 },
	{ limite: 180, giro: 95, verde: 2.0, ambar: 0.6, roja: 2.2, monedas: 16 }
][Math.max(0, juego.nivelDificultad)];

One row per difficulty and everything that changes in a single place: the time limit, the speed of the arm, how long each light of the corridor lasts and how many coins there are. The scene changes the shape of the level; this table, its timings.

Match state and each player's state#

scripts/principal.ts (lines 26 to 38)
let fase: 'menu' | 'cuenta' | 'carrera' | 'fin' = 'menu';
let libre = false;
let totalMonedas = 0;
let crono: Cronometro;
let luz: 'verde' | 'ambar' | 'roja' = 'verde';
let rojaDesde = 0;
let musica = 0;
let ascensorOcupado = false;
let juez: Actor;

/** Estado de cada jugador. Vive en jugador.datos para que cada uno tenga el suyo. */
interface Estado { control: number; monedas: number; llego: boolean; cubierto: boolean }
const estado = (j: Jugador) => j.datos as unknown as Estado;

What belongs to everyone (the phase, the corridor light, whether the elevator is busy) is variables of the file. What belongs to each one (their checkpoint, their coins, whether they already arrived) lives in jugador.datos. The function estado only gives it a type. With that separation, the same script works with one player or with four.

fase is a minimal state machine: almost every event starts by asking which phase the match is in.

The start: state, menu and events#

Everything is registered inside juego.alIniciar. First, each player's state:

scripts/principal.ts (lines 50 to 54)
	jugadores.alEntrar((j) => {
		j.datos = { control: 0, monedas: 0, llego: false, cubierto: false };
		j.congelado = true; // nadie se mueve hasta la luz verde
		pintarHud(j);
	});

Then, a menu that offers two modes and recalls the best time saved with progreso:

scripts/principal.ts (lines 56 to 73)
	// El menu de inicio se abre para todos; solo cuenta lo que elige una persona.
	const humano = jugadores.humanos()[0];
	const mejor = humano ? progreso.leer<number | null>(humano, `mejor-${juego.dificultad}`, null) : null;
	interfaz.menu({
		id: 'inicio',
		titulo: interfaz.t('menuTitulo'),
		texto: mejor === null ? interfaz.t('menuTexto') : interfaz.t('menuTextoMejor', { tiempo: mejor.toFixed(2) }),
		opciones: [
			{ id: 'carrera', texto: interfaz.t('menuCarrera') },
			{ id: 'paseo', texto: interfaz.t('menuPaseo') }
		]
	});
	interfaz.alElegir((menu, opcion, jugador) => {
		if (menu !== 'inicio' || jugador.esBot || fase !== 'menu') return;
		libre = opcion === 'paseo';
		interfaz.cerrarMenu('inicio');
		cuentaRegresiva();
	});

Three details: a menu does not pause the match, which is why the players are frozen; the choice of a filler bot is ignored (jugador.esBot); and the progress key includes the difficulty, so each one has its own best time.

Coins: seeded randomness and touch by tag#

The coins are created at the start. There are 31 possible places and each match uses some of them:

scripts/principal.ts (lines 161 to 177)
function sembrarMonedas(): void {
	// azar sale de la semilla de la partida: con la misma semilla, las monedas quedan en los mismos lugares.
	const elegidos = azar.mezclar(LUGARES).slice(0, AJUSTES.monedas);
	totalMonedas = elegidos.length;
	for (const posicion of elegidos) {
		const moneda = mundo.crear({
			forma: 'cilindro',
			posicion,
			rotacion: [90, 0, 0],
			tamano: [0.9, 0.16, 0.9],
			material: { color: 'ambar', acabado: 'brillante' },
			solida: false,
			etiquetas: ['moneda']
		});
		moneda.girar([0, 160, 0]);
	}
}

mundo.crear takes the same format as a piece of the scene. Since the randomness comes from azar, two matches with the same seed have the same coins: that is why the bot can test the game and the result repeats.

A single event handles all of them:

scripts/principal.ts (lines 75 to 82)
	mundo.alTocar('#moneda', (moneda, j) => {
		interfaz.flotante(interfaz.t('menosUno'), moneda.posicion, { tipo: 'exito', para: j });
		moneda.eliminar();
		estado(j).monedas++;
		j.puntos++;
		sonido.tocar('moneda', { para: j, tono: 1 + estado(j).monedas * 0.02 });
		pintarHud(j);
	});

The sound rises a little in pitch with each coin, and everything carries para: j: only the one who collected it sees and hears it. interfaz.flotante is the "-1 s" that rises from the coin.

Dangers, trampoline and checkpoints#

scripts/principal.ts (lines 84 to 106)
	mundo.alTocar('#peligro', (_pieza, j) => {
		if (fase !== 'carrera' || estado(j).llego) return;
		sonido.tocar('golpe', { para: j });
		interfaz.mensaje(interfaz.t('golpe'), { tipo: 'peligro', para: j });
		j.reaparecer();
	});

	mundo.alTocar('#trampolin', (_pieza, j) => {
		j.impulsar([0, 21, 5]);
		sonido.tocar('poder', { para: j });
	});

	mundo.alEntrar('#control', (zona, j) => {
		const n = CONTROLES.indexOf(zona.id.replace('zona-', '')) + 1;
		if (n <= estado(j).control) return;
		estado(j).control = n;
		j.aparicion = CONTROLES[n - 1];
		pintarHud(j);
		if (j.esBot) return;
		mundo.pieza(`luz-control-${n}`).material = { color: 'verde', acabado: 'neon' };
		sonido.tocar('control', { para: j });
		interfaz.mensaje(interfaz.t('controlAlcanzado', { n }), { tipo: 'exito', duracion: 1.6, para: j });
	});
  • j.reaparecer() returns the player to their current spawn point. j.aparicion = ... changes it: that is a checkpoint.
  • The checkpoint event takes advantage of the names in the scene: from the zone zona-control-2 come the spawn point control-2 and the light luz-control-2.
  • if (n <= estado(j).control) return; prevents going back from "deactivating" a checkpoint.

The sweeper: a piece moved every frame#

scripts/principal.ts (lines 132 to 140)
juego.cadaCuadro(() => {
	// La barredora: un brazo que da vueltas alrededor del eje. Se mueve a mano para mostrar posicion y rotacion.
	const brazo = mundo.pieza('brazo');
	const centro = brazo.datos.centro as Vec3;
	const radio = brazo.datos.radio as number;
	const grados = (reloj.ahora * AJUSTES.giro) % 360;
	const a = (grados * Math.PI) / 180;
	brazo.posicion = [centro[0] + Math.cos(a) * radio, centro[1], centro[2] - Math.sin(a) * radio];
	brazo.rotacion = [0, grados, 0];

The angle comes from reloj.ahora, the match time, not from a counter of its own: it stops with the pause and is the same on every repetition. The position is assigned as a new vector. The center and the radius come from datos in the scene.

Since the angle depends only on time, the bot can wait until the arm is at a safe angle (giroY), as shown further down.

The red light corridor#

The most elaborate stretch. A cycle of timers changes the light:

scripts/principal.ts (lines 224 to 246, trimmed)
function cicloDelCorredor(nueva: 'verde' | 'ambar' | 'roja'): void {
	if (fase === 'fin') return;
	luz = nueva;
	const semaforo = mundo.pieza('semaforo-corredor');
	// ...
	const color = nueva === 'verde' ? 'verde' : nueva === 'ambar' ? 'ambar' : 'coral';
	for (const banda of mundo.piezas('banda')) banda.material = { color, acabado: 'neon' };
	// Las etiquetas le sirven al bot de prueba para saber cuando partir (ver pruebas/bot.json).
	semaforo.desetiquetar('recien-verde');
	if (nueva === 'verde') {
		semaforo.etiquetar('recien-verde');
		reloj.despues(0.3, () => semaforo.desetiquetar('recien-verde'));
	}
	if (nueva === 'roja') rojaDesde = reloj.ahora;
	// ...
	const duracion = nueva === 'verde' ? AJUSTES.verde : nueva === 'ambar' ? AJUSTES.ambar : AJUSTES.roja;
	reloj.despues(duracion, () => cicloDelCorredor(nueva === 'verde' ? 'ambar' : nueva === 'ambar' ? 'roja' : 'verde'));
}

The function calls itself again with reloj.despues: there is no setTimeout. The strips on the floor change color so that the light can be seen even if the player is not looking at the traffic light. And the tag recien-verde, which lasts 0.3 seconds, exists for the bot.

Every frame it checks who moves on a red light:

scripts/principal.ts (lines 142 to 157)
	if (fase !== 'carrera') return;
	for (const j of mundo.zona('zona-corredor').jugadores()) {
		const cubierto = !loVeElSemaforo(j);
		if (cubierto !== estado(j).cubierto) {
			estado(j).cubierto = cubierto;
			interfaz.hud('cubierto', cubierto ? { texto: interfaz.t('aCubierto'), lugar: 'abajo-centro' } : null, j);
		}
		if (luz !== 'roja' || reloj.ahora - rojaDesde < 0.35 || cubierto) continue;
		const [vx, , vz] = j.velocidad;
		if (Math.hypot(vx, vz) > 1.2) {
			sonido.tocar('alerta', { para: j });
			interfaz.mensaje(interfaz.t('pillado'), { tipo: 'peligro', para: j });
			j.reaparecer();
		}
	}
});

Three fairness decisions: only those inside the corridor zone are checked; there are 0.35 seconds of grace from the moment the light turns red; and a speed of up to 1.2 is tolerated (the avatar braking).

The corridor on a green light. The strips on the floor take the color of the light; the striped blocks are the parapets. The on-screen text is in Spanish.

The parapets work as hiding places thanks to a ray:

scripts/principal.ts (lines 248 to 256)
/** Un rayo desde el semaforo hasta el pecho del jugador: si choca antes con un parapeto, el jugador esta a cubierto. */
function loVeElSemaforo(j: Jugador): boolean {
	const ojo: Vec3 = [0, 7.9, 105.9];
	const p = j.posicion;
	const hacia: Vec3 = [p[0] - ojo[0], p[1] + 1 - ojo[1], p[2] - ojo[2]];
	const distancia = Math.hypot(hacia[0], hacia[1], hacia[2]);
	const impacto = mundo.rayo(ojo, hacia, distancia);
	return impacto === null || impacto.distancia > distancia - 0.6;
}

mundo.rayo hits only solid pieces and ignores players: if the ray reaches the player without hitting anything first, the traffic light sees them.

The elevator: a player action#

scripts/principal.ts (lines 117 to 120)
	jugadores.alAccion('accion', (j) => {
		if (ascensorOcupado || !mundo.zona('zona-ascensor').jugadores().includes(j)) return;
		subirAscensor();
	});

alAccion fires once per press, wherever the player is. The script decides whether it counts: only if they are inside the elevator zone and the elevator is free. subirAscensor moves it with pieza.moverA and, on arrival, schedules the way down with alLlegar and reloj.despues. Moving platforms carry whoever is standing on them.

The ending#

scripts/principal.ts (lines 278 to 304, trimmed)
function llegar(j: Jugador): void {
	const e = estado(j);
	if (e.llego || fase !== 'carrera') return;
	e.llego = true;
	j.congelado = true;
	if (j.esBot) {
		interfaz.mensaje(interfaz.t('botLlego', { nombre: j.nombre }));
		return;
	}
	fase = 'fin';
	crono.detener();
	sonido.detener(musica);
	sonido.tocar('victoria');
	const tiempo = crono.valor;
	const final = Math.max(0, Math.round((tiempo - e.monedas) * 100) / 100);
	// ...
	const mejor = progreso.leer<number | null>(j, `mejor-${juego.dificultad}`, null);
	if (mejor === null || final < mejor) progreso.guardar(j, `mejor-${juego.dificultad}`, final);
	reloj.despues(1.2, () => {
		juego.terminar({ resultado: 'victoria', puntaje: final, tabla: 'tiempo', de: j, detalle: interfaz.t('finDetalle', { tiempo: tiempo.toFixed(2), monedas: e.monedas }) });
	});
}
  • If a filler bot arrives, it is announced and the match goes on: the match belongs to the person.
  • The score is the time minus one second per coin, rounded to hundredths.
  • juego.terminar with tabla: 'tiempo' and de: j records the score. detalle is the line the end panel shows under the result.
  • juego.terminar is called 1.2 seconds after arriving, with reloj.despues.

If time runs out, another function ends with a defeat, with no table:

scripts/principal.ts (lines 306 to 313)
function sinTiempo(): void {
	if (fase !== 'carrera') return;
	fase = 'fin';
	sonido.detener(musica);
	sonido.tocar('derrota');
	const humano = jugadores.humanos()[0];
	juego.terminar({ resultado: 'derrota', detalle: interfaz.t('sinTiempo', { n: humano ? estado(humano).control : 0, total: CONTROLES.length }) });
}

Both paths end in juego.terminar. It is rule 8 of scripts: every match must end.

The end panel is provided by the game player. The script only hands over the result, score, table and detail: here, 41.67 seconds minus 15 coins. The on-screen text is in Spanish.

The four embellishments#

Four embellishments of the script use what was added after the first version of the contract:

WhatWhereWhat for
actores.crear and juez.seguirRutaLines 45 and 203A judge who waits by the start and strolls around when the race begins.
camara.tomaLine 186During the countdown, the camera looks at the traffic light from the side and comes back by itself.
interfaz.rotuloLines 48, 265 and 270A label over the elevator, which shows a progress bar while it goes up.
interfaz.flotanteLine 76The "-1 s" that comes out of each coin.

None of them changes the rules of the game: without them the race is the same.

The bot script#

pruebas/bot.json plays the whole race in 34 steps. It starts by choosing the mode in the menu and waiting for the barrier to lift:

pruebas/bot.json (beginning)
{
	"$schema": "../../../packages/sdk/esquemas/bot.schema.json",
	"formato": 1,
	"tiempoMaximo": 170,
	"esperado": { "resultado": "victoria" },
	"pasos": [
		{ "elegir": "inicio:carrera" },
		{ "esperarPieza": "barrera", "cerca": [0, 3.4, 5], "radio": 0.3 },
		{ "ir": [0, 0, 9] },
		{ "ir": [1.5, 0, 13.5] },
		{ "ir": [-1.5, 0, 18] },
		{ "ir": [0, 0, 22.5] },
		{ "ir": "aparicion:control-1", "control": true },
  • One ir per slab. The four points are the centers of the four slabs. The bot goes in a straight line and jumps by itself at the edge.
  • control: true at each checkpoint: if the game sends the bot back (the arm swept it, it moved on a red light), it resumes from there and not from the beginning.
  • tiempoMaximo is 170 and the limit of the shortest difficulty is 180: the test demands winning with a margin.

The rest of the bot script repeats one idea: wait until the world is in the right state and then walk.

pruebas/bot.json (the trampoline, the sweeper and the elevator)
		{ "ir": [0, 0, 41.9] },
		{ "ir": [0, 4, 52], "saltar": "no" },
json
		{ "esperarPieza": "brazo", "giroY": [115, 150] },
		{ "ir": [3, 4, 61.5] },
		{ "ir": [3, 4, 65.5] },
		{ "ir": [0, 4, 70.5] },
json
		{ "ir": [0, 4.4, 125] },
		{ "pulsar": "accion" },
		{ "esperarPieza": "ascensor", "cerca": [0, 10.2, 125], "radio": 0.1 },
		{ "ir": "zona:meta" }
  • At the trampoline, "saltar": "no": the game gives the push.
  • At the sweeper it waits for an angle of the arm and goes around the plate by one side in three steps.
  • At the elevator it stands on it, presses the action, waits for the piece to reach the top and walks to the goal.

The corridor is crossed in three dashes, each one after waiting for the tag recien-verde. Those steps are commented in Testing with the bot.

What tests each thing#

What is checkedWith what
The files are valid JSON and comply with the schemasgreengames validar
Every piece, zone, spawn point, table and text key that the script names existsgreengames validar
The script uses nothing that does not exist in the room, and its types matchgreengames validar
The two lists of texts have the same keys and variablesgreengames validar
The match can be won on all three difficultiesgreengames probar, with the bot script
The narrow slabs of dificil can be jumped; the corridor can be crossedThe bot script, which goes through there on each difficulty
No event throws errors and no limit is exceededgreengames probar
With the same seed, the match repeats identicallygreengames probar, which repeats the first run
The coins change with the seedThe two seeds give different scores

Real output of greengames probar on a copy of the game:

greengames probar circuito-luz-verde
  OK    facil semilla 1: victoria, puntaje 26.67, 44.9 s de partida en 325 ms; script 0.018 ms/cuadro (max 0.84), 99 piezas
  OK    facil semilla 2: victoria, puntaje 27.67, 44.9 s de partida en 285 ms; script 0.019 ms/cuadro (max 0.82), 99 piezas
  OK    normal semilla 1: victoria, puntaje 30.83, 47.0 s de partida en 273 ms; script 0.016 ms/cuadro (max 0.81), 90 piezas
  OK    normal semilla 2: victoria, puntaje 31.83, 47.0 s de partida en 334 ms; script 0.017 ms/cuadro (max 1.02), 90 piezas
  OK    dificil semilla 1: victoria, puntaje 32.78, 45.0 s de partida en 279 ms; script 0.017 ms/cuadro (max 0.77), 86 piezas
  OK    dificil semilla 2: victoria, puntaje 33.78, 45.0 s de partida en 319 ms; script 0.017 ms/cuadro (max 1.37), 86 piezas
circuito-luz-verde: prueba aprobada (6 de 6 corridas, .ggx de 9.0 KB)

What you can read there:

  • On facil, the match lasts 44.9 seconds with both seeds and the score is 26.67 with one and 27.67 with the other: the same course and one second of difference, which is one coin.
  • There are more pieces on facil (99) than on dificil (86): the bridges and the extra coins.
  • The script spends less than 0.02 ms per frame on average, far below the millisecond recommended by the quality checklist.

What the bot script does not test: the stroll mode, the defeat on time and the match with filler bots. For the defeat you would need a second bot script that expects to lose.

Next#