Players & movement
Players around you (client)
Enumerate the other players a client can already see β ids, bodies, distances and the nearest one β with no permission at all, and why a player id does not outlive a body.
On this page5 sections
Open77.players reads the identities, positions and distances of players rendered by the client. These client-side queries require no permission.
resource "proximity_demo"
version "1.0.0"
client_script "client/main.lua"
-- no permissions block: the enumeration below is ungated
Player IDs and entity handles
This is a streaming-scoped view, not the server roster. It holds the players this client has been told about β the same roster that draws the nameplates β and nothing more. A player the server counts as connected can be missing from it for a long time: a different routing bucket, too far to stream, a proxy body still spawning, a world transition in progress. The list is also per-client, so two players standing together will not necessarily see the same one.
So:
| Question | Ask |
|---|---|
| Who is standing near me, and where is his body? | Open77.players on the client |
| How many players are on this server? Is this id on the whitelist? Who gets the payout? | the server, over a net callback |
A resource that treats #Open77.players.all() as a head count writes a race that a quiet test
server will never reproduce and a busy one will hit within the hour. The client cannot be made
authoritative about a roster it only receives a slice of; the fix is always to ask the server.
The API
| Function | Answers |
|---|---|
Open77.players.all(options?) |
{ playerId, ... }, ascending, the local player included |
Open77.players.localId() |
your own player id |
Open77.players.entity(playerId) |
the entity id of that player's body |
Open77.players.fromEntity(entityId) |
which player wears that body |
Open77.players.nearby(radius, options?) |
everyone with a body within radius, nearest first |
Open77.players.closest(options?) |
just the nearest one |
Failures follow the repository convention: nil, reason, with stable snake_case tokens. The reason
matters here more than usual, because "he has no body yet" and "I have never heard of him" are
different facts and a proximity resource has to act differently on each.
localId() β and why it is not local()
local is a reserved word in Lua, so Open77.players.local() is a syntax error: the parser
refuses the name before anything runs. The function is registered under both spellings, so
Open77.players.localId() -- write this
Open77.players["local"]() -- identical: the same function value
Both return your player id, or nil, "no_session" when you are not connected to a server.
all(options?)
for _, id in ipairs(Open77.players.all()) do
print(id, id == Open77.players.localId() and "(me)" or "")
end
-- only the ones that actually have a body right now
local visible = Open77.players.all({ streamed = true })
Ascending by player id, so two calls in the same frame compare cleanly. Your own id is in the
list, matching FiveM's GetActivePlayers; filter it out yourself if you do not want it.
A player who is connected but has no body β his proxy has not spawned, or it was retired for a
death, a re-dress or a world transition β stays in the list with no entity. Dropping him would
make "not streamed yet" indistinguishable from "left the server", which is exactly the distinction
this API exists to preserve. { streamed = true } is how you ask for only the bodied ones; it is
the equivalent of FiveM's NetworkIsPlayerActive filter.
A player who leaves is gone from the list immediately, and so is the id of a body that was retired: see Ids do not outlive bodies.
entity(playerId) and fromEntity(entityId)
The two directions of the mapping. Feed the entity into any Open77 entity API β
Open77.character.state(entity) first among them β for the transform, stance, weapon and vehicle.
For the same body's own axes β a point two metres in front of it, or where its head is β see
an entity's own axes.
local id = Open77.players.localId()
local entity = Open77.players.entity(id)
local state = Open77.character.state(entity)
print(("%s is at %.1f, %.1f"):format(id, state.position.x, state.position.y))
entity() accepts the player id as a number or as a decimal string. It returns:
| Result | Meaning |
|---|---|
| an entity id | that player has a body, and this addresses it |
nil, "player_not_streamed" |
he is here, he has no body β do not retry in a loop, wait for him |
nil, "unknown_player" |
this client has never heard of that id |
nil, "invalid_player" |
the argument was not an id at all |
Your own body is the entity id 0. That is not a failure value: 0 is the reserved id that
every Open77 entity API reads as "the local player", which is why Open77.character.state() with no
argument and Open77.character.state(Open77.players.entity(Open77.players.localId())) are the same
call. fromEntity(0) gives your player id back.
fromEntity() takes the entity as a number or a decimal string β the string form is what a raycast
hit or the older Open77.animations._context() map hands out β and answers nil, "unknown_entity"
for anything that is not a live player body right now.
nearby(radius, options?) and closest(options?)
Centred on your own body, sorted by ascending distance, ties broken by player id so that "closest" does not flicker between two bodies standing on the same spot.
for _, player in ipairs(Open77.players.nearby(15.0)) do
print(("%s at %.1f m"):format(player.name, player.distance))
end
local target = Open77.players.closest({ radius = 3.0 })
if target then
print(("you are next to %s"):format(target.name))
end
Each entry is:
| Field | |
|---|---|
playerId |
the network id |
entity |
the body, ready for Open77.character.state |
distance |
metres from the origin |
position |
world position β a plain { x, y, z } table, which is a vector3 |
name |
display name |
isLocal |
true only for your own entry, and only if you asked for it |
Options, shared by both:
| Option | Default | |
|---|---|---|
origin |
your own body | measure from somewhere else β a raycast hit, a door, a marker |
includeSelf |
false |
put your own entry in the result |
limit |
unlimited | nearby only, 1..256, applied after sorting |
radius |
unlimited | closest only; nearby takes it as its first argument |
Because position is a plain x/y/z table, it is already a vector everywhere a vector is accepted:
local a, b = Open77.players.nearby(50)[1], Open77.players.nearby(50)[2]
print(#(vector3(a.position) - vector3(b.position))) -- metres between them
Failure reasons:
| Reason | Meaning |
|---|---|
no_session |
not connected to a server |
no_local_position |
you are connected but have no body β the "continue" screen, or a world transition β so there is no centre to measure from. Pass origin to ask anyway |
no_player_in_range |
closest only: nobody qualified. nearby returns an empty table instead |
invalid_radius / invalid_origin / invalid_limit |
the argument was malformed |
Players without a body are skipped by both: they have no position to sort by. Use all() if you
need to know they exist.
Ids do not outlive bodies
A proxy body is destroyed and recreated more often than players expect β on death, on a clothing change, on a world transition. When it goes, the entity id goes with it:
local entity = Open77.players.entity(otherPlayer) -- 12884901889
-- ... he dies and respawns ...
Open77.players.fromEntity(entity) --> nil, "unknown_entity"
Open77.players.entity(otherPlayer) --> a different id
Cache the player id, never the entity id. The player id is stable for the whole session; the entity id is a handle on one particular body. Resolving it again is one table lookup, so there is nothing to gain by holding it.
Permission
None. The roster is what the client is already rendering.
The two things a roster could leak that a screenshot cannot β life state and stats β are not here:
Open77.players.getLifeState, isDead, allLifeStates, getHealthState and allHealthStates stay
behind players.life.read in the same table, and the stats reads behind players.stats.read. Asking
who is standing in front of you does not grant you their health bar.
Relation to the server API
The dedicated server has its own Open77.players with the same names and different authority β its
roster is the real one, and it can act on it. See the server API. A resource that
needs a truth the client cannot have asks for it over a net callback:
-- server: the authoritative answer
Open77.net.register("population", function(source)
return #Open77.players.all()
end)
-- client, inside a CreateThread, with `network.events` in the manifest
local count = Open77.net.callAwait("population")// For agents & LLMsthis page as Markdownllms.txtllms-full.txt
