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
- The idea in one sentence
- The manifest
- The scene
- Difficulties in the scene
- Tags and ids
- Checkpoints
- The script, part by part
- Settings per difficulty
- Match state and each player's state
- The start: state, menu and events
- Coins: seeded randomness and touch by tag
- Dangers, trampoline and checkpoints
- The sweeper: a piece moved every frame
- The red light corridor
- The elevator: a player action
- The ending
- The four embellishments
- The bot script
- What tests each thing
- Next
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.
| File | Size |
|---|---|
juego.json | The manifest, with 32 texts per language |
escenas/principal.json | 109 lines: 79 pieces, 5 spawn points, 7 zones, 4 lights |
scripts/principal.ts | 340 lines |
pruebas/bot.json | 34 steps |
The resulting .ggx | 9.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):
"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: 4allows playing with up to three bots. The script is written for a list of players, not for one.- Two actions.
saltarandaccion. The text ofaccionsays what it does in this game, "Call the lift", and that is what the player reads in the controls table.accion2is not declared, so it has no touch button and does not appear in the help.
"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) withnormalas 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:
| Stretch | z | What is there | What it teaches |
|---|---|---|---|
| Start | 0 to 5 | A platform, a three-light traffic light and a barrier | Pieces with an id that the script lights up and moves |
| Slabs | 9 to 22 | Four slabs over the void | Different pieces per difficulty |
| Moving platform | 32 to 38 | A platform that goes back and forth | movimiento declared in the scene |
| Trampoline | 43 | A neon disc that launches you to the upper level | A tag plus jugador.impulsar |
| Sweeper | 63 | A plate with an arm that spins | A piece moved by the script every frame, with datos |
| Corridor | 74 to 106 | A hallway with a traffic light and three parapets | Zone, ray and tags that change |
| Elevator and goal | 115 to 131 | Loose boxes, an elevator and the goal arch | Dynamic 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.
{ "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"] }, { "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), andcontrolon 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:
{ "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 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#
// --- 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#
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:
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:
// 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:
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:
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#
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-2come the spawn pointcontrol-2and the lightluz-control-2. if (n <= estado(j).control) return;prevents going back from "deactivating" a checkpoint.
The sweeper: a piece moved every frame#
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:
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:
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 parapets work as hiding places thanks to a ray:
/** 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#
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#
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.terminarwithtabla: 'tiempo'andde: jrecords the score.detalleis the line the end panel shows under the result.juego.terminaris called 1.2 seconds after arriving, withreloj.despues.
If time runs out, another function ends with a defeat, with no table:
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 four embellishments#
Four embellishments of the script use what was added after the first version of the contract:
| What | Where | What for |
|---|---|---|
actores.crear and juez.seguirRuta | Lines 45 and 203 | A judge who waits by the start and strolls around when the race begins. |
camara.toma | Line 186 | During the countdown, the camera looks at the traffic light from the side and comes back by itself. |
interfaz.rotulo | Lines 48, 265 and 270 | A label over the elevator, which shows a progress bar while it goes up. |
interfaz.flotante | Line 76 | The "-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:
{
"$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
irper 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: trueat 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.tiempoMaximois 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.
{ "ir": [0, 0, 41.9] },
{ "ir": [0, 4, 52], "saltar": "no" }, { "esperarPieza": "brazo", "giroY": [115, 150] },
{ "ir": [3, 4, 61.5] },
{ "ir": [3, 4, 65.5] },
{ "ir": [0, 4, 70.5] }, { "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 checked | With what |
|---|---|
| The files are valid JSON and comply with the schemas | greengames validar |
| Every piece, zone, spawn point, table and text key that the script names exists | greengames validar |
| The script uses nothing that does not exist in the room, and its types match | greengames validar |
| The two lists of texts have the same keys and variables | greengames validar |
| The match can be won on all three difficulties | greengames probar, with the bot script |
The narrow slabs of dificil can be jumped; the corridor can be crossed | The bot script, which goes through there on each difficulty |
| No event throws errors and no limit is exceeded | greengames probar |
| With the same seed, the match repeats identically | greengames probar, which repeats the first run |
| The coins change with the seed | The two seeds give different scores |
Real output of greengames probar on a copy of the game:
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 ondificil(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#
- Scripts: the rules this script follows.
- API reference: each function used here, with its signature.
- Your first game in 15 minutes: make one of your own, with no code.